From 4adfc55dc6902f56e6d070e14fba768a0ff05bd3 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sun, 9 Jun 2024 07:55:32 +0200 Subject: [PATCH] Bring back providers They are still used by Lexical and we need to figure out what to do with them later --- lib/elixir_sense.ex | 17 + .../providers/completion/completion_engine.ex | 1528 +++++++++++++++++ .../providers/completion/generic_reducer.ex | 91 + .../providers/completion/reducer.ex | 14 + .../completion/reducers/bitstring.ex | 39 + .../completion/reducers/callbacks.ex | 161 ++ .../completion/reducers/complete_engine.ex | 137 ++ .../completion/reducers/docs_snippets.ex | 42 + .../completion/reducers/overridable.ex | 96 ++ .../providers/completion/reducers/params.ex | 80 + .../providers/completion/reducers/protocol.ex | 83 + .../providers/completion/reducers/returns.ex | 97 ++ .../providers/completion/reducers/struct.ex | 155 ++ .../completion/reducers/type_specs.ex | 180 ++ .../providers/completion/suggestion.ex | 277 +++ .../providers/definition/locator.ex | 291 ++++ lib/elixir_sense/providers/hover/docs.ex | 676 ++++++++ .../providers/implementation/locator.ex | 333 ++++ lib/elixir_sense/providers/location.ex | 362 ++++ lib/elixir_sense/providers/plugins/ecto.ex | 136 ++ .../providers/plugins/ecto/query.ex | 270 +++ .../providers/plugins/ecto/schema.ex | 457 +++++ .../providers/plugins/ecto/types.ex | 108 ++ .../providers/plugins/module_store.ex | 88 + lib/elixir_sense/providers/plugins/option.ex | 38 + lib/elixir_sense/providers/plugins/phoenix.ex | 105 ++ .../providers/plugins/phoenix/scope.ex | 116 ++ lib/elixir_sense/providers/plugins/plugin.ex | 27 + lib/elixir_sense/providers/plugins/util.ex | 86 + .../providers/references/locator.ex | 435 +++++ .../providers/signature_help/signature.ex | 174 ++ lib/elixir_sense/providers/utils/field.ex | 62 + lib/elixir_sense/providers/utils/matcher.ex | 72 + 33 files changed, 6833 insertions(+) create mode 100644 lib/elixir_sense/providers/completion/completion_engine.ex create mode 100644 lib/elixir_sense/providers/completion/generic_reducer.ex create mode 100644 lib/elixir_sense/providers/completion/reducer.ex create mode 100644 lib/elixir_sense/providers/completion/reducers/bitstring.ex create mode 100644 lib/elixir_sense/providers/completion/reducers/callbacks.ex create mode 100644 lib/elixir_sense/providers/completion/reducers/complete_engine.ex create mode 100644 lib/elixir_sense/providers/completion/reducers/docs_snippets.ex create mode 100644 lib/elixir_sense/providers/completion/reducers/overridable.ex create mode 100644 lib/elixir_sense/providers/completion/reducers/params.ex create mode 100644 lib/elixir_sense/providers/completion/reducers/protocol.ex create mode 100644 lib/elixir_sense/providers/completion/reducers/returns.ex create mode 100644 lib/elixir_sense/providers/completion/reducers/struct.ex create mode 100644 lib/elixir_sense/providers/completion/reducers/type_specs.ex create mode 100644 lib/elixir_sense/providers/completion/suggestion.ex create mode 100644 lib/elixir_sense/providers/definition/locator.ex create mode 100644 lib/elixir_sense/providers/hover/docs.ex create mode 100644 lib/elixir_sense/providers/implementation/locator.ex create mode 100644 lib/elixir_sense/providers/location.ex create mode 100644 lib/elixir_sense/providers/plugins/ecto.ex create mode 100644 lib/elixir_sense/providers/plugins/ecto/query.ex create mode 100644 lib/elixir_sense/providers/plugins/ecto/schema.ex create mode 100644 lib/elixir_sense/providers/plugins/ecto/types.ex create mode 100644 lib/elixir_sense/providers/plugins/module_store.ex create mode 100644 lib/elixir_sense/providers/plugins/option.ex create mode 100644 lib/elixir_sense/providers/plugins/phoenix.ex create mode 100644 lib/elixir_sense/providers/plugins/phoenix/scope.ex create mode 100644 lib/elixir_sense/providers/plugins/plugin.ex create mode 100644 lib/elixir_sense/providers/plugins/util.ex create mode 100644 lib/elixir_sense/providers/references/locator.ex create mode 100644 lib/elixir_sense/providers/signature_help/signature.ex create mode 100644 lib/elixir_sense/providers/utils/field.ex create mode 100644 lib/elixir_sense/providers/utils/matcher.ex diff --git a/lib/elixir_sense.ex b/lib/elixir_sense.ex index 4dbd3967..18f9f00a 100644 --- a/lib/elixir_sense.ex +++ b/lib/elixir_sense.ex @@ -77,4 +77,21 @@ defmodule ElixirSense do other -> other end end + + defdelegate docs(code, line, column, options \\ []), to: ElixirSense.Providers.Hover.Docs + + defdelegate definition(code, line, column, options \\ []), + to: ElixirSense.Providers.Definition.Locator + + defdelegate implementations(code, line, column, options \\ []), + to: ElixirSense.Providers.Implementation.Locator + + defdelegate suggestions(code, line, column, options \\ []), + to: ElixirSense.Providers.Completion.Suggestion + + defdelegate signature(code, line, column, options \\ []), + to: ElixirSense.Providers.SignatureHelp.Signature + + defdelegate references(code, line, column, trace, options \\ []), + to: ElixirSense.Providers.References.Locator end diff --git a/lib/elixir_sense/providers/completion/completion_engine.ex b/lib/elixir_sense/providers/completion/completion_engine.ex new file mode 100644 index 00000000..36ed51bd --- /dev/null +++ b/lib/elixir_sense/providers/completion/completion_engine.ex @@ -0,0 +1,1528 @@ +# This file includes modified code extracted from the elixir project. Namely: +# +# https://github.com/elixir-lang/elixir/blob/v1.1/lib/iex/lib/iex/autocomplete.exs +# +# The original code is licensed as follows: +# +# Copyright 2012 Plataformatec +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This module is based on IEx.Autocomplete from version ~ 1.1 +# with some changes inspired by Alchemist.Completer (itself based on IEx.Autocomplete). +# Since then the codebases have diverged as the requirements +# put on editor and REPL autocomplete are different. +# However some relevant changes have been merged back +# from upstream Elixir (1.13). +# Changes made to the original version include: +# - different result format with added docs and spec +# - built in and private funcs are not excluded +# - hint generation removed +# - added expansion basing on metadata besides introspection +# - uses custom docs extraction function +# - gets metadata by argument instead of environment variables +# (original Elixir 1.1) and later GenServer +# - no signature completion as it's handled by signature provider +# - added attribute completion +# - improved completion after %, ^ and & operators + +defmodule ElixirSense.Providers.Completion.CompletionEngine do + @moduledoc """ + Provides generic completion for functions, macros, attributes, variables + """ + alias ElixirSense.Core.Applications + alias ElixirSense.Core.Behaviours + alias ElixirSense.Core.Binding + alias ElixirSense.Core.BuiltinAttributes + alias ElixirSense.Core.BuiltinFunctions + alias ElixirSense.Core.Introspection + alias ElixirSense.Core.Metadata + alias ElixirSense.Core.Normalized.Code, as: NormalizedCode + alias ElixirSense.Core.Source + alias ElixirSense.Core.State + alias ElixirSense.Core.Struct + alias ElixirSense.Core.TypeInfo + + alias ElixirSense.Providers.Utils.Matcher + require Logger + + @module_results_cache_key :"#{__MODULE__}_module_results_cache" + + @erlang_module_builtin_functions [{:module_info, 0}, {:module_info, 1}] + @elixir_module_builtin_functions [{:__info__, 1}] + @builtin_functions @erlang_module_builtin_functions ++ @elixir_module_builtin_functions + + @type attribute :: %{ + type: :attribute, + name: String.t(), + summary: String.t() | nil + } + + @type variable :: %{ + type: :variable, + name: String.t() + } + + @type func :: %{ + type: :function | :macro, + visibility: :public | :private, + name: String.t(), + needed_require: String.t() | nil, + needed_import: {String.t(), {String.t(), integer()}} | nil, + arity: non_neg_integer, + def_arity: non_neg_integer, + args: String.t(), + args_list: [String.t()], + origin: String.t(), + summary: String.t(), + spec: String.t(), + snippet: String.t() | nil, + metadata: map + } + + @type mod :: %{ + type: :module, + name: String.t(), + subtype: ElixirSense.Core.Introspection.module_subtype(), + summary: String.t(), + metadata: map, + required_alias: String.t() | nil + } + + @type field :: %{ + type: :field, + subtype: :struct_field | :map_key, + name: String.t(), + origin: String.t() | nil, + call?: boolean, + type_spec: String.t() | nil + } + + @type t() :: + mod() + | func() + | variable() + | field() + | attribute() + + @spec complete(String.t(), State.Env.t(), Metadata.t(), {pos_integer, pos_integer}, keyword()) :: + [t()] + def complete(hint, %State.Env{} = env, %Metadata{} = metadata, cursor_position, opts \\ []) do + do_expand(hint |> String.to_charlist(), env, metadata, cursor_position, opts) + end + + def do_expand(code, %State.Env{} = env, %Metadata{} = metadata, cursor_position, opts \\ []) do + # TODO remove when we require elixir 1.13 + only_structs = + case code do + [?% | _] -> true + _ -> false + end + + case NormalizedCode.Fragment.cursor_context(code) do + {:alias, hint} when is_list(hint) -> + expand_aliases(List.to_string(hint), env, metadata, cursor_position, false, opts) + + {:alias, prefix, hint} -> + expand_prefixed_aliases(prefix, hint, env, metadata, cursor_position, false) + + {:unquoted_atom, unquoted_atom} -> + expand_erlang_modules(List.to_string(unquoted_atom), env, metadata) + + {:dot, path, hint} -> + expand_dot( + path, + List.to_string(hint), + false, + env, + metadata, + cursor_position, + only_structs, + opts + ) + + {:dot_arity, path, hint} -> + expand_dot( + path, + List.to_string(hint), + true, + env, + metadata, + cursor_position, + only_structs, + opts + ) + + {:dot_call, _path, _hint} -> + # no need to expand signatures here, we have signatures provider + # IEx calls + # expand_dot_call(path, List.to_atom(hint), env) + # to provide signatures and falls back to expand_local_or_var + expand_expr(env, metadata, cursor_position, opts) + + :expr -> + # IEx calls expand_local_or_var("", env) + # we choose to return more and handle some special cases + # TODO expand_expr(env) after we require elixir 1.13 + case code do + [?^] -> expand_var("", env, metadata) + [?%] -> expand_aliases("", env, metadata, cursor_position, true, opts) + _ -> expand_expr(env, metadata, cursor_position, opts) + end + + {:local_or_var, local_or_var} -> + # TODO consider suggesting struct fields here when we require elixir 1.13 + # expand_struct_fields_or_local_or_var(code, List.to_string(local_or_var), shell) + expand_local_or_var(List.to_string(local_or_var), env, metadata, cursor_position) + + {:local_arity, local} -> + expand_local(List.to_string(local), true, env, metadata, cursor_position) + + {:local_call, _local} -> + # no need to expand signatures here, we have signatures provider + # expand_local_call(List.to_atom(local), env) + # IEx calls + # expand_dot_call(path, List.to_atom(hint), env) + # to provide signatures and falls back to expand_local_or_var + expand_expr(env, metadata, cursor_position, opts) + + # elixir >= 1.13 + {:operator, operator} -> + case operator do + [?^] -> expand_var("", env, metadata) + [?&] -> expand_expr(env, metadata, cursor_position, opts) + _ -> expand_local(List.to_string(operator), false, env, metadata, cursor_position) + end + + # elixir >= 1.13 + {:operator_arity, operator} -> + expand_local(List.to_string(operator), true, env, metadata, cursor_position) + + # elixir >= 1.13 + {:operator_call, _operator} -> + expand_local_or_var("", env, metadata, cursor_position) + + # elixir >= 1.13 + {:sigil, []} -> + expand_sigil(env, metadata, cursor_position) + + # elixir >= 1.13 + {:sigil, [_]} -> + # {:yes, [], ~w|" """ ' ''' \( / < [ { \||c} + # we choose to not provide sigil chars + no() + + # elixir >= 1.13 + {:struct, struct} when is_list(struct) -> + expand_aliases(List.to_string(struct), env, metadata, cursor_position, true, opts) + + # elixir >= 1.14 + {:struct, {:alias, prefix, hint}} -> + expand_prefixed_aliases(prefix, hint, env, metadata, cursor_position, true) + + # elixir >= 1.14 + {:struct, {:dot, path, hint}} -> + expand_dot(path, List.to_string(hint), false, env, metadata, cursor_position, true, opts) + + # elixir >= 1.14 + {:struct, {:module_attribute, attribute}} -> + expand_attribute(List.to_string(attribute), env, metadata) + + # elixir >= 1.14 + {:struct, {:local_or_var, local_or_var}} -> + # TODO consider suggesting struct fields here when we require elixir 1.13 + # expand_struct_fields_or_local_or_var(code, List.to_string(local_or_var), shell) + expand_local_or_var(List.to_string(local_or_var), env, metadata, cursor_position) + + {:module_attribute, attribute} -> + expand_attribute(List.to_string(attribute), env, metadata) + + # elixir >= 1.16 + {:anonymous_call, _} -> + expand_expr(env, metadata, cursor_position, opts) + + :none -> + no() + end + end + + defp expand_dot( + path, + hint, + exact?, + %State.Env{} = env, + %Metadata{} = metadata, + cursor_position, + only_structs, + opts + ) do + filter = struct_module_filter(only_structs, env, metadata) + + case expand_dot_path(path, env, metadata) do + {:ok, {:atom, mod}} when hint == "" -> + expand_aliases( + mod, + "", + [], + not only_structs, + env, + metadata, + cursor_position, + filter, + opts + ) + + {:ok, {:atom, mod}} -> + expand_require(mod, hint, exact?, env, metadata, cursor_position) + + {:ok, {:map, fields, _}} -> + expand_map_field_access(fields, hint, :map, env, metadata) + + {:ok, {:struct, fields, type, _}} -> + expand_map_field_access(fields, hint, {:struct, type}, env, metadata) + + _ -> + no() + end + end + + # elixir >= 1.14 + defp expand_dot_path({:var, ~c"__MODULE__"}, %State.Env{} = env, %Metadata{} = _metadata) do + if env.module != nil and Introspection.elixir_module?(env.module) do + {:ok, {:atom, env.module}} + else + :error + end + end + + defp expand_dot_path({:var, var}, %State.Env{} = env, %Metadata{} = metadata) do + value_from_binding({:variable, List.to_atom(var)}, env, metadata) + end + + defp expand_dot_path({:module_attribute, attribute}, %State.Env{} = env, %Metadata{} = metadata) do + value_from_binding({:attribute, List.to_atom(attribute)}, env, metadata) + end + + defp expand_dot_path({:alias, hint}, %State.Env{} = env, %Metadata{} = metadata) do + alias = hint |> List.to_string() |> String.split(".") |> value_from_alias(env, metadata) + + case alias do + {:ok, atom} -> {:ok, {:atom, atom}} + :error -> :error + end + end + + # elixir >= 1.14 + defp expand_dot_path( + {:alias, {:local_or_var, var}, hint}, + %State.Env{} = env, + %Metadata{} = metadata + ) do + case var do + ~c"__MODULE__" -> + alias_suffix = hint |> List.to_string() |> String.split(".") + alias = [{:__MODULE__, [], nil} | alias_suffix] |> value_from_alias(env, metadata) + + case alias do + {:ok, atom} -> {:ok, {:atom, atom}} + :error -> :error + end + + _ -> + :error + end + end + + defp expand_dot_path( + {:alias, {:module_attribute, attribute}, hint}, + %State.Env{} = env, + %Metadata{} = metadata + ) do + case value_from_binding({:attribute, List.to_atom(attribute)}, env, metadata) do + {:ok, {:atom, atom}} -> + if Introspection.elixir_module?(atom) do + alias_suffix = hint |> List.to_string() |> String.split(".") + alias = (Module.split(atom) ++ alias_suffix) |> value_from_alias(env, metadata) + + case alias do + {:ok, atom} -> {:ok, {:atom, atom}} + :error -> :error + end + else + :error + end + + :error -> + :error + end + end + + defp expand_dot_path({:alias, _, _hint}, %State.Env{} = _env, %Metadata{} = _metadata) do + :error + end + + defp expand_dot_path({:unquoted_atom, var}, %State.Env{} = _env, %Metadata{} = _metadata) do + {:ok, {:atom, List.to_atom(var)}} + end + + defp expand_dot_path({:dot, parent, call}, %State.Env{} = env, %Metadata{} = metadata) do + case expand_dot_path(parent, env, metadata) do + {:ok, expanded} -> + value_from_binding({:call, expanded, List.to_atom(call), []}, env, metadata) + + :error -> + :error + end + end + + # elixir >= 1.15 + defp expand_dot_path(:expr, %State.Env{} = _env, %Metadata{} = _metadata) do + # TODO expand expression + :error + end + + defp expand_expr(%State.Env{} = env, %Metadata{} = metadata, cursor_position, opts) do + local_or_var = expand_local_or_var("", env, metadata, cursor_position) + erlang_modules = expand_erlang_modules("", env, metadata) + elixir_modules = expand_aliases("", env, metadata, cursor_position, false, opts) + attributes = expand_attribute("", env, metadata) + + local_or_var ++ erlang_modules ++ elixir_modules ++ attributes + end + + defp no do + [] + end + + ## Formatting + + defp format_expansion(entries) do + Enum.flat_map(entries, &to_entries/1) + end + + defp expand_map_field_access(fields, hint, type, %State.Env{} = env, %Metadata{} = metadata) do + # when there is only one matching field and it's exact to the hint + # and it's not a nested map, iex does not return completions + # We choose to return it normally + match_map_fields(fields, hint, type, env, metadata) + |> format_expansion() + end + + defp expand_require( + mod, + hint, + exact?, + %State.Env{} = env, + %Metadata{} = metadata, + cursor_position + ) do + format_expansion( + match_module_funs(mod, hint, exact?, true, :all, env, metadata, cursor_position) + ) + end + + ## Expand local or var + + defp expand_local_or_var(hint, %State.Env{} = env, %Metadata{} = metadata, cursor_position) do + format_expansion( + match_var(hint, env, metadata) ++ match_local(hint, false, env, metadata, cursor_position) + ) + end + + defp expand_local(hint, exact?, %State.Env{} = env, %Metadata{} = metadata, cursor_position) do + format_expansion(match_local(hint, exact?, env, metadata, cursor_position)) + end + + defp expand_var(hint, %State.Env{} = env, %Metadata{} = metadata) do + variables = match_var(hint, env, metadata) + format_expansion(variables) + end + + defp expand_sigil(%State.Env{} = env, %Metadata{} = metadata, cursor_position) do + sigils = + match_local("sigil_", false, env, metadata, cursor_position) + |> Enum.filter(fn %{name: name} -> String.starts_with?(name, "sigil_") end) + |> Enum.map(fn %{name: "sigil_" <> rest} = local -> + %{local | name: "~" <> rest} + end) + + locals = match_local("~", false, env, metadata, cursor_position) + + format_expansion(sigils ++ locals) + end + + defp match_local(hint, exact?, %State.Env{} = env, %Metadata{} = metadata, cursor_position) do + kernel_special_forms_locals = + match_module_funs( + Kernel.SpecialForms, + hint, + exact?, + false, + :all, + env, + metadata, + cursor_position + ) + + current_module_locals = + match_module_funs(env.module, hint, exact?, false, :all, env, metadata, cursor_position) + + imported_locals = + {env.functions, env.macros} + |> Introspection.combine_imports() + |> Enum.flat_map(fn {scope_import, imported} -> + match_module_funs( + scope_import, + hint, + exact?, + false, + imported, + env, + metadata, + cursor_position + ) + end) + + kernel_special_forms_locals ++ current_module_locals ++ imported_locals + end + + defp match_var(hint, %State.Env{vars: vars}, %Metadata{} = _metadata) do + for( + %State.VarInfo{name: name} when is_atom(name) <- vars, + name = Atom.to_string(name), + Matcher.match?(name, hint), + do: name + ) + |> Enum.sort() + |> Enum.map(&%{kind: :variable, name: &1}) + end + + # do not suggest attributes outside of a module + defp expand_attribute(_, %State.Env{module: module}, %Metadata{} = _metadata) + when module == nil, + do: no() + + defp expand_attribute( + hint, + %State.Env{attributes: attributes} = env, + %Metadata{} = _metadata + ) do + attribute_names = + attributes + |> Enum.map(fn %State.AttributeInfo{name: name} -> name end) + + attribute_names = + case env do + %State.Env{function: {_fun, _arity}} -> + attribute_names + + %State.Env{module: module} when not is_nil(module) -> + # include module attributes in module scope + attribute_names ++ BuiltinAttributes.all() + + _ -> + [] + end + + for( + attribute_name when is_atom(attribute_name) <- attribute_names, + name = Atom.to_string(attribute_name), + Matcher.match?(name, hint), + do: attribute_name + ) + |> Enum.sort() + |> Enum.map( + &%{ + kind: :attribute, + name: Atom.to_string(&1), + summary: BuiltinAttributes.docs(&1) + } + ) + |> format_expansion() + end + + ## Erlang modules + + defp expand_erlang_modules(hint, %State.Env{} = env, %Metadata{} = metadata) do + format_expansion(match_erlang_modules(hint, env, metadata)) + end + + defp match_erlang_modules(hint, %State.Env{} = env, %Metadata{} = metadata) do + for mod <- match_modules(hint, true, env, metadata), + usable_as_unquoted_module?(mod) do + mod_as_atom = String.to_atom(mod) + + case :persistent_term.get({@module_results_cache_key, mod_as_atom}, nil) do + nil -> get_erlang_module_result(mod_as_atom) + result -> result + end + end + end + + def fill_erlang_module_cache(module, docs) do + get_erlang_module_result(module, docs) + end + + defp get_erlang_module_result(module, docs \\ nil) do + subtype = Introspection.get_module_subtype(module) + desc = Introspection.get_module_docs_summary(module, docs) + + name = inspect(module) + + result = %{ + kind: :module, + name: name, + full_name: name, + type: :erlang, + desc: desc, + subtype: subtype + } + + :persistent_term.put({@module_results_cache_key, module}, result) + result + end + + defp struct_module_filter(true, %State.Env{} = _env, %Metadata{} = metadata) do + fn module -> Struct.is_struct(module, metadata.structs) end + end + + defp struct_module_filter(false, %State.Env{} = _env, %Metadata{} = _metadata) do + fn _ -> true end + end + + ## Elixir modules + + defp expand_aliases( + all, + %State.Env{} = env, + %Metadata{} = metadata, + cursor_position, + only_structs, + opts + ) do + filter = struct_module_filter(only_structs, env, metadata) + + case String.split(all, ".") do + [hint] -> + aliases = match_aliases(hint, env, metadata) + expand_aliases(Elixir, hint, aliases, false, env, metadata, cursor_position, filter, opts) + + parts -> + hint = List.last(parts) + list = Enum.take(parts, length(parts) - 1) + + case value_from_alias(list, env, metadata) do + {:ok, alias} -> + expand_aliases( + alias, + hint, + [], + false, + env, + metadata, + cursor_position, + filter, + Keyword.put(opts, :required_alias, false) + ) + + :error -> + no() + end + end + end + + defp expand_aliases( + mod, + hint, + aliases, + include_funs, + %State.Env{} = env, + %Metadata{} = metadata, + cursor_position, + filter, + opts + ) do + aliases + |> Kernel.++(match_elixir_modules(mod, hint, env, metadata, filter, opts)) + |> Kernel.++( + if include_funs, + do: match_module_funs(mod, hint, false, true, :all, env, metadata, cursor_position), + else: [] + ) + |> format_expansion() + end + + defp expand_prefixed_aliases( + {:local_or_var, ~c"__MODULE__"}, + hint, + %State.Env{} = env, + %Metadata{} = metadata, + cursor_position, + only_structs + ) do + if env.module != nil and Introspection.elixir_module?(env.module) do + expand_aliases("#{env.module}.#{hint}", env, metadata, cursor_position, only_structs, []) + else + no() + end + end + + defp expand_prefixed_aliases( + {:module_attribute, attribute}, + hint, + %State.Env{} = env, + %Metadata{} = metadata, + cursor_position, + only_structs + ) do + case value_from_binding({:attribute, List.to_atom(attribute)}, env, metadata) do + {:ok, {:atom, atom}} -> + if Introspection.elixir_module?(atom) do + expand_aliases("#{atom}.#{hint}", env, metadata, cursor_position, only_structs, []) + else + no() + end + + {:ok, _} -> + # this clause can match e.g. in + # @abc %{SOME: 123} + # @abc.SOME + # but this code does not compile as it defines an invalid alias + no() + + :error -> + no() + end + end + + defp expand_prefixed_aliases( + _, + _hint, + %State.Env{} = _env, + %Metadata{} = _metadata, + _cursor_position, + _only_structs + ), + do: no() + + defp value_from_alias(mod_parts, %State.Env{} = env, %Metadata{} = _metadata) do + mod_parts + |> Enum.map(fn + bin when is_binary(bin) -> String.to_atom(bin) + other -> other + end) + |> Source.concat_module_parts(env.module, env.aliases) + end + + defp match_aliases(hint, %State.Env{} = env, %Metadata{} = _metadata) do + for {alias, mod} <- env.aliases, + [name] = Module.split(alias), + Matcher.match?(name, hint) do + %{ + kind: :module, + type: :elixir, + name: name, + full_name: inspect(mod), + desc: {"", %{}}, + subtype: Introspection.get_module_subtype(mod) + } + end + end + + defp match_elixir_modules( + module, + hint, + %State.Env{} = env, + %Metadata{} = metadata, + filter, + opts + ) do + name = Atom.to_string(module) + depth = length(String.split(name, ".")) + 1 + base = name <> "." <> hint + + concat_module = fn + ["Elixir", "Elixir" | _] = parts -> parts |> tl() |> Module.concat() + parts -> Module.concat(parts) + end + + for mod <- match_modules(base, module === Elixir, env, metadata), + mod_as_atom = mod |> String.to_atom(), + filter.(mod_as_atom), + parts = String.split(mod, "."), + depth <= length(parts), + name = Enum.at(parts, depth - 1), + valid_alias_piece?("." <> name), + concatted = parts |> Enum.take(depth) |> concat_module.(), + filter.(concatted) do + {name, concatted, false} + end + |> Kernel.++( + match_elixir_modules_that_require_alias(module, hint, env, metadata, filter, opts) + ) + |> Enum.reject(fn + {_, concatted, true} -> + Enum.find(env.aliases, fn {_as, module} -> + concatted == module + end) + + _rest -> + false + end) + |> Enum.uniq_by(&elem(&1, 1)) + |> Enum.map(fn {name, module, required_alias?} -> + result = + case metadata.mods_funs_to_positions[{module, nil, nil}] do + nil -> + case :persistent_term.get({@module_results_cache_key, module}, nil) do + nil -> get_elixir_module_result(module) + result -> result + end + + info -> + %{ + kind: :module, + type: :elixir, + full_name: inspect(module), + desc: {Introspection.extract_summary_from_docs(info.doc), info.meta}, + subtype: Metadata.get_module_subtype(metadata, module) + } + end + + result = Map.put(result, :name, name) + + if required_alias? do + Map.put(result, :required_alias, module) + else + result + end + end) + end + + def fill_elixir_module_cache(module, docs) do + get_elixir_module_result(module, docs) + end + + defp get_elixir_module_result(module, docs \\ nil) do + {desc, meta} = Introspection.get_module_docs_summary(module, docs) + subtype = Introspection.get_module_subtype(module) + + result = %{ + kind: :module, + type: :elixir, + full_name: inspect(module), + desc: {desc, meta}, + subtype: subtype + } + + :persistent_term.put({@module_results_cache_key, module}, result) + result + end + + defp valid_alias_piece?(<>) when char in ?A..?Z, + do: valid_alias_rest?(rest) + + defp valid_alias_piece?(_), + do: false + + defp valid_alias_rest?(<>) + when char in ?A..?Z + when char in ?a..?z + when char in ?0..?9 + when char == ?_, + do: valid_alias_rest?(rest) + + defp valid_alias_rest?(<<>>), + do: true + + defp valid_alias_rest?(rest), + do: valid_alias_piece?(rest) + + ## Helpers + + # Version.match? is slow, we need to avoid it in a hot loop + if Version.match?(System.version(), ">= 1.14.0-dev") do + defp usable_as_unquoted_module?(name) do + # Conversion to atom is not a problem because + # it is only called with existing modules names. + # credo:disable-for-lines:7 + Macro.classify_atom(String.to_atom(name)) in [:identifier, :unquoted] and + not String.starts_with?(name, "Elixir.") + end + else + defp usable_as_unquoted_module?(name) do + Code.Identifier.classify(String.to_atom(name)) != :other and + not String.starts_with?(name, "Elixir.") + end + end + + defp match_elixir_modules_that_require_alias( + Elixir, + hint, + %State.Env{} = env, + %Metadata{} = metadata, + filter, + opts + ) do + if Keyword.get(opts, :required_alias) do + for {suggestion, required_alias} <- + find_elixir_modules_that_require_alias(Elixir, hint, env, metadata), + mod_as_atom = required_alias |> String.to_atom(), + filter.(mod_as_atom), + required_alias_mod = required_alias |> String.split(".") |> Module.concat() do + {suggestion, required_alias_mod, true} + end + else + [] + end + end + + defp match_elixir_modules_that_require_alias( + _module, + _hint, + %State.Env{} = _env, + %Metadata{} = _metadata, + _filter, + _opts + ), + do: [] + + defp find_elixir_modules_that_require_alias(Elixir, hint, env, metadata) do + get_modules(true, env, metadata) + |> Enum.sort() + |> Enum.dedup() + |> Enum.reduce([], fn + "Elixir." <> module = full_module, acc -> + subtype = Introspection.get_module_subtype(String.to_atom(full_module)) + # skip mix tasks and protocol implementations as it's not common to need to alias those + # credo:disable-for-next-line + if subtype not in [:implementation, :task] do + # do not search for a match in Elixir. prefix - no need to alias it + module_parts = module |> String.split(".") + + case module_parts do + [_] -> + # no need to alias if module is 1 part + acc + + [_root | rest] -> + rest + |> Enum.with_index(1) + |> Enum.filter(fn {module_part, _index} -> + Matcher.match?(module_part, hint) + end) + |> Enum.reduce(acc, fn {module_part, index}, acc1 -> + required_alias = Enum.slice(module_parts, 0..index) + required_alias = required_alias |> Module.concat() |> Atom.to_string() + + [{module_part, required_alias} | acc1] + end) + end + else + acc + end + + _erlang_module, acc -> + # skip erlang modules + acc + end) + |> Enum.sort() + |> Enum.dedup() + |> Enum.filter(fn {suggestion, _required_alias} -> valid_alias_piece?("." <> suggestion) end) + end + + defp match_modules(hint, root, %State.Env{} = env, %Metadata{} = metadata) do + hint_parts = hint |> String.split(".") + hint_parts_length = length(hint_parts) + [hint_suffix | hint_prefix] = hint_parts |> Enum.reverse() + + root + |> get_modules(env, metadata) + |> Enum.sort() + |> Enum.dedup() + |> Enum.filter(fn mod -> + [mod_suffix | mod_prefix] = + mod |> String.split(".") |> Enum.take(hint_parts_length) |> Enum.reverse() + + hint_prefix == mod_prefix and Matcher.match?(mod_suffix, hint_suffix) + end) + end + + defp get_modules(true, %State.Env{} = env, %Metadata{} = metadata) do + ["Elixir.Elixir"] ++ get_modules(false, env, metadata) + end + + defp get_modules(false, %State.Env{} = env, %Metadata{} = metadata) do + # TODO consider changing this to :code.all_available when otp 23 is required + modules = Enum.map(:code.all_loaded(), &Atom.to_string(elem(&1, 0))) + + # TODO it seems we only run in interactive mode - remove the check? + case :code.get_mode() do + :interactive -> + modules ++ get_modules_from_applications() ++ get_modules_from_metadata(env, metadata) + + _otherwise -> + modules ++ get_modules_from_metadata(env, metadata) + end + end + + defp get_modules_from_applications do + for module <- Applications.get_modules_from_applications() do + Atom.to_string(module) + end + end + + defp get_modules_from_metadata(%State.Env{} = _env, %Metadata{} = metadata) do + for {{k, nil, nil}, _} <- metadata.mods_funs_to_positions, do: Atom.to_string(k) + end + + defp match_module_funs( + mod, + hint, + exact?, + include_builtin, + imported, + %State.Env{} = env, + %Metadata{} = metadata, + cursor_position + ) do + falist = + cond do + metadata.mods_funs_to_positions |> Map.has_key?({mod, nil, nil}) -> + get_metadata_module_funs(mod, include_builtin, env, metadata, cursor_position) + + match?({:module, _}, ensure_loaded(mod)) -> + get_module_funs(mod, include_builtin) + + true -> + [] + end + |> Enum.sort_by(fn {f, a, _, _, _, _, _} -> {f, -a} end) + + list = + Enum.reduce(falist, [], fn {f, a, def_a, func_kind, {doc_str, meta}, spec, arg}, acc -> + doc = {Introspection.extract_summary_from_docs(doc_str), meta} + + case :lists.keyfind(f, 1, acc) do + {f, aa, def_arities, func_kinds, docs, specs, args} -> + :lists.keyreplace( + f, + 1, + acc, + {f, [a | aa], [def_a | def_arities], [func_kind | func_kinds], [doc | docs], + [spec | specs], [arg | args]} + ) + + false -> + [{f, [a], [def_a], [func_kind], [doc], [spec], [arg]} | acc] + end + end) + + for {fun, arities, def_arities, func_kinds, docs, specs, args} <- list, + name = Atom.to_string(fun), + if(exact?, do: name == hint, else: Matcher.match?(name, hint)) do + needed_requires = + for func_kind <- func_kinds do + if func_kind in [:macro, :defmacro, :defguard] and mod not in env.requires and + mod != Kernel.SpecialForms do + mod + end + end + + needed_imports = + if imported == :all do + arities |> Enum.map(fn _ -> nil end) + else + arities + |> Enum.map(fn a -> + if {fun, a} not in imported do + {mod, {fun, a}} + end + end) + end + + %{ + kind: :function, + name: name, + arities: arities, + def_arities: def_arities, + module: mod, + func_kinds: func_kinds, + docs: docs, + specs: specs, + needed_requires: needed_requires, + needed_imports: needed_imports, + args: args + } + end + |> Enum.sort_by(& &1.name) + end + + # TODO filter by hint here? + defp get_metadata_module_funs( + mod, + include_builtin, + %State.Env{} = env, + %Metadata{} = metadata, + cursor_position + ) do + case metadata.mods_funs_to_positions[{mod, nil, nil}] do + nil -> + [] + + _funs -> + # local macros are available after definition + # local functions are hoisted + for {{^mod, f, a}, %State.ModFunInfo{} = info} <- metadata.mods_funs_to_positions, + a != nil, + (mod == env.module and not include_builtin) or Introspection.is_pub(info.type), + mod != env.module or State.ModFunInfo.get_category(info) != :macro or + List.last(info.positions) < cursor_position, + include_builtin || {f, a} not in @builtin_functions do + behaviour_implementation = + Metadata.get_module_behaviours(metadata, env, mod) + |> Enum.find_value(fn behaviour -> + if Introspection.is_callback(behaviour, f, a, metadata) do + behaviour + end + end) + + {specs, docs, meta} = + case behaviour_implementation do + nil -> + case metadata.specs[{mod, f, a}] do + nil -> + {"", info.doc, info.meta} + + %State.SpecInfo{specs: specs} -> + {specs |> Enum.reverse() |> Enum.join("\n"), info.doc, info.meta} + end + + behaviour -> + meta = Map.merge(info.meta, %{implementing: behaviour}) + + case metadata.specs[{behaviour, f, a}] do + %State.SpecInfo{} = spec_info -> + specs = spec_info.specs |> Enum.reverse() + + {callback_doc, callback_meta} = + case metadata.mods_funs_to_positions[{behaviour, f, a}] do + nil -> + {spec_info.doc, spec_info.meta} + + def_info -> + # in case of protocol implementation get doc and meta from def + {def_info.doc, def_info.meta} + end + + spec = + specs |> Enum.reject(&String.starts_with?(&1, "@spec")) |> Enum.join("\n") + + {spec, callback_doc, callback_meta |> Map.merge(meta)} + + nil -> + Metadata.get_doc_spec_from_behaviour( + behaviour, + f, + a, + State.ModFunInfo.get_category(info) + ) + end + end + + # assume function head is first in code and last in metadata + head_params = Enum.at(info.params, -1) + args = head_params |> Enum.map(&Macro.to_string/1) + default_args = Introspection.count_defaults(head_params) + + # TODO this is useless - we duplicate and then deduplicate + for arity <- (a - default_args)..a do + {f, arity, a, info.type, {docs, meta}, specs, args} + end + end + |> Enum.concat() + end + end + + # TODO filter by hint here? + def get_module_funs(mod, include_builtin) do + docs = NormalizedCode.get_docs(mod, :docs) + module_specs = TypeInfo.get_module_specs(mod) + + callback_specs = + for behaviour <- Behaviours.get_module_behaviours(mod), + {fa, spec} <- TypeInfo.get_module_callbacks(behaviour), + into: %{}, + do: {fa, {behaviour, spec}} + + if docs != nil and function_exported?(mod, :__info__, 1) do + exports = mod.__info__(:macros) ++ mod.__info__(:functions) ++ special_builtins(mod) + # TODO this is useless - we should only return max arity variant + default_arg_functions = default_arg_functions(docs) + + for {f, a} <- exports do + {f, new_arity} = + case default_arg_functions[{f, a}] do + nil -> {f, a} + new_arity -> {f, new_arity} + end + + {func_kind, func_doc} = find_doc({f, new_arity}, docs) + func_kind = func_kind || :function + + doc = + case func_doc do + nil -> + app = ElixirSense.Core.Applications.get_application(mod) + # TODO provide docs for builtin + if f in [:behaviour_info | @builtin_functions] do + {"", %{builtin: true, app: app}} + else + {"", %{app: app}} + end + + {{_fun, _}, _line, _kind, _args, doc, metadata} -> + {doc, metadata} + end + + spec_key = + case func_kind do + :macro -> {:"MACRO-#{f}", new_arity + 1} + :function -> {f, new_arity} + end + + {_behaviour, fun_spec, spec_kind} = + case callback_specs[spec_key] do + nil -> + {nil, module_specs[spec_key], :spec} + + {behaviour, fun_spec} -> + {behaviour, fun_spec, if(func_kind == :macro, do: :macrocallback, else: :callback)} + end + + spec = Introspection.spec_to_string(fun_spec, spec_kind) + + fun_args = Introspection.extract_fun_args(func_doc) + + # TODO check if this is still needed on 1.13+ + # as of Elixir 1.12 some functions/macros, e.g. Kernel.SpecialForms.fn + # have broken specs in docs + # in that case we fill a dummy fun_args + fun_args = + if length(fun_args) != new_arity do + format_params(nil, new_arity) + else + fun_args + end + + {f, a, new_arity, func_kind, doc, spec, fun_args} + end + |> Kernel.++( + for {f, a} <- @builtin_functions, + include_builtin, + do: {f, a, a, :function, {"", %{}}, nil, nil} + ) + else + funs = + if Code.ensure_loaded?(mod) do + mod.module_info(:exports) + |> Kernel.--(if include_builtin, do: [], else: @builtin_functions) + |> Kernel.++(BuiltinFunctions.erlang_builtin_functions(mod)) + else + [] + end + + for {f, a} <- funs do + # we don't expect macros here + {behaviour, fun_spec} = + case callback_specs[{f, a}] do + nil -> {nil, module_specs[{f, a}]} + callback -> callback + end + + # we load typespec anyway, no big win reading erlang spec from meta[:signature] + + doc_result = + if docs != nil do + {_kind, func_doc} = find_doc({f, a}, docs) + + case func_doc do + nil -> + if behaviour do + {"", %{implementing: behaviour}} + else + {"", %{}} + end + + {{_fun, _}, _line, _kind, _args, doc, metadata} -> + {doc, metadata} + end + else + if behaviour do + {"", %{implementing: behaviour}} + else + {"", %{}} + end + end + + params = format_params(fun_spec, a) + spec = Introspection.spec_to_string(fun_spec, if(behaviour, do: :callback, else: :spec)) + + {f, a, a, :function, doc_result, spec, params} + end + end + end + + defp format_params({{_name, _arity}, [params | _]}, _arity_1) do + TypeInfo.extract_params(params) + end + + defp format_params(nil, 0), do: [] + + defp format_params(nil, arity) do + for _ <- 1..arity, do: "term" + end + + defp special_builtins(mod) do + if Code.ensure_loaded?(mod) do + mod.module_info(:exports) + |> Enum.filter(fn {f, a} -> + {f, a} in [{:behaviour_info, 1}] + end) + else + [] + end + end + + defp find_doc(fun, _docs) when fun in @builtin_functions, do: {:function, nil} + + defp find_doc(fun, docs) do + doc = + docs + |> Enum.find(&match?({^fun, _, _, _, _, _}, &1)) + + case doc do + nil -> {nil, nil} + {_, _, func_kind, _, _, _} = d -> {func_kind, d} + end + end + + defp default_arg_functions(docs) do + for {{fun_name, arity}, _, _kind, args, _, _} <- docs, + count = Introspection.count_defaults(args), + count > 0, + new_arity <- (arity - count)..(arity - 1), + into: %{}, + do: {{fun_name, new_arity}, arity} + end + + defp ensure_loaded(Elixir), do: {:error, :nofile} + defp ensure_loaded(mod), do: Code.ensure_compiled(mod) + + defp match_map_fields(fields, hint, type, %State.Env{} = _env, %Metadata{} = metadata) do + {subtype, origin, types} = + case type do + {:struct, {:atom, mod}} -> + types = + ElixirSense.Providers.Utils.Field.get_field_types( + metadata, + mod, + true + ) + + {:struct_field, inspect(mod), types} + + {:struct, nil} -> + {:struct_field, nil, %{}} + + :map -> + {:map_key, nil, %{}} + + other -> + raise "unexpected #{inspect(other)} for hint #{inspect(hint)}" + end + + for {key, value} when is_atom(key) <- fields, + key_str = Atom.to_string(key), + not Regex.match?(~r/^[A-Z]/u, key_str), + Matcher.match?(key_str, hint) do + value_is_map = + case value do + {:map, _, _} -> true + {:struct, _, _, _} -> true + _ -> false + end + + %{ + kind: :field, + name: key_str, + subtype: subtype, + value_is_map: value_is_map, + origin: origin, + type_spec: types[key] + } + end + |> Enum.sort_by(& &1.name) + end + + ## Ad-hoc conversions + @spec to_entries(map) :: [t()] + defp to_entries(%{ + kind: :field, + subtype: subtype, + name: name, + origin: origin, + type_spec: type_spec + }) do + [ + %{ + type: :field, + name: name, + subtype: subtype, + origin: origin, + call?: true, + type_spec: if(type_spec, do: Macro.to_string(type_spec)) + } + ] + end + + defp to_entries( + %{ + kind: :module, + name: name, + full_name: full_name, + desc: {desc, metadata}, + subtype: subtype + } = map + ) do + [ + %{ + type: :module, + name: name, + full_name: full_name, + required_alias: if(map[:required_alias], do: inspect(map[:required_alias])), + subtype: subtype, + summary: desc, + metadata: metadata + } + ] + end + + defp to_entries(%{kind: :variable, name: name}) do + [%{type: :variable, name: name}] + end + + defp to_entries(%{kind: :attribute, name: name, summary: summary}) do + [%{type: :attribute, name: "@" <> name, summary: summary}] + end + + defp to_entries(%{ + kind: :function, + name: name, + arities: arities, + def_arities: def_arities, + needed_imports: needed_imports, + needed_requires: needed_requires, + module: mod, + func_kinds: func_kinds, + docs: docs, + specs: specs, + args: args + }) do + for e <- + Enum.zip([ + arities, + docs, + specs, + args, + def_arities, + func_kinds, + needed_imports, + needed_requires + ]), + {a, {doc, metadata}, spec, args, def_arity, func_kind, needed_import, needed_require} = e do + kind = + case func_kind do + k when k in [:macro, :defmacro, :defmacrop, :defguard, :defguardp] -> :macro + _ -> :function + end + + visibility = + if func_kind in [:defp, :defmacrop, :defguardp] do + :private + else + :public + end + + mod_name = inspect(mod) + + fa = {name |> String.to_atom(), a} + + if fa in (BuiltinFunctions.all() -- [exception: 1, message: 1]) do + args = BuiltinFunctions.get_args(fa) + docs = BuiltinFunctions.get_docs(fa) + + %{ + type: kind, + visibility: visibility, + name: name, + arity: a, + def_arity: def_arity, + args: args |> Enum.join(", "), + args_list: args, + needed_require: nil, + needed_import: nil, + origin: mod_name, + summary: Introspection.extract_summary_from_docs(docs), + metadata: %{builtin: true}, + spec: BuiltinFunctions.get_specs(fa) |> Enum.join("\n"), + snippet: nil + } + else + needed_import = + case needed_import do + nil -> nil + {mod, {fun, arity}} -> {inspect(mod), {Atom.to_string(fun), arity}} + end + + %{ + type: kind, + visibility: visibility, + name: name, + arity: a, + def_arity: def_arity, + args: args |> Enum.join(", "), + args_list: args, + needed_require: if(needed_require, do: inspect(needed_require)), + needed_import: needed_import, + origin: mod_name, + summary: doc, + metadata: metadata, + spec: spec || "", + snippet: nil + } + end + end + end + + defp value_from_binding(binding_ast, %State.Env{} = env, %Metadata{} = metadata) do + case Binding.expand( + Binding.from_env(env, metadata), + binding_ast + ) do + :none -> :error + nil -> :error + other -> {:ok, other} + end + end +end diff --git a/lib/elixir_sense/providers/completion/generic_reducer.ex b/lib/elixir_sense/providers/completion/generic_reducer.ex new file mode 100644 index 00000000..b6da569d --- /dev/null +++ b/lib/elixir_sense/providers/completion/generic_reducer.ex @@ -0,0 +1,91 @@ +defmodule ElixirSense.Providers.Completion.GenericReducer do + @moduledoc """ + A generic behaviour for reducers that customize suggestions + according to the cursor's position in a function call. + """ + + require Logger + alias ElixirSense.Providers.Plugins.Util + + @type func_call :: {module, fun :: atom, arg :: non_neg_integer, any} + @type suggestion :: ElixirSense.Providers.Completion.Suggestion.generic() + @type reducer_name :: atom() + + @callback suggestions(hint :: String.t(), func_call, [func_call], opts :: map) :: + :ignore + | {:add | :override, [suggestion]} + | {:add | :override, [suggestion], [reducer_name]} + + defmacro __using__(_) do + quote do + @behaviour unquote(__MODULE__) + + def reduce(hint, env, buffer_metadata, cursor_context, acc) do + unquote(__MODULE__).reduce(__MODULE__, hint, env, buffer_metadata, cursor_context, acc) + end + end + end + + def reduce(reducer, hint, env, buffer_metadata, cursor_context, acc) do + text_before = cursor_context.text_before + + opts = %{ + env: env, + buffer_metadata: buffer_metadata, + cursor_context: cursor_context, + module_store: acc.context.module_store + } + + case Util.func_call_chain(text_before, env, buffer_metadata) do + [func_call | _] = chain -> + if function_exported?(reducer, :suggestions, 4) do + try do + reducer.suggestions(hint, func_call, chain, opts) |> handle_suggestions(acc) + catch + kind, payload -> + {payload, stacktrace} = Exception.blame(kind, payload, __STACKTRACE__) + message = Exception.format(kind, payload, stacktrace) + Logger.error("Error in suggestions reducer: #{message}") + {:cont, acc} + end + else + {:cont, acc} + end + + [] -> + if function_exported?(reducer, :suggestions, 2) do + try do + reducer.suggestions(hint, opts) |> handle_suggestions(acc) + catch + kind, payload -> + {payload, stacktrace} = Exception.blame(kind, payload, __STACKTRACE__) + message = Exception.format(kind, payload, stacktrace) + Logger.error("Error in suggestions reducer: #{message}") + {:cont, acc} + end + else + {:cont, acc} + end + end + end + + def handle_suggestions(:ignore, acc) do + {:cont, acc} + end + + def handle_suggestions({:add, suggestions}, acc) do + {:cont, %{acc | result: suggestions ++ acc.result}} + end + + def handle_suggestions({:add, suggestions, reducers}, acc) do + {:cont, %{acc | result: suggestions ++ acc.result, reducers: reducers}} + end + + def handle_suggestions({:override, suggestions}, acc) do + {:halt, %{acc | result: suggestions}} + end + + def handle_suggestions({:override, suggestions, reducers}, acc) do + {:cont, %{acc | result: suggestions, reducers: reducers}} + end +end diff --git a/lib/elixir_sense/providers/completion/reducer.ex b/lib/elixir_sense/providers/completion/reducer.ex new file mode 100644 index 00000000..012088a9 --- /dev/null +++ b/lib/elixir_sense/providers/completion/reducer.ex @@ -0,0 +1,14 @@ +defmodule ElixirSense.Providers.Completion.Reducer do + @moduledoc !""" + Provides common functions for reducers. + """ + + def put_context(acc, key, value) do + updated_context = Map.put(acc.context, key, value) + put_in(acc.context, updated_context) + end + + def get_context(acc, key) do + get_in(acc, [:context, key]) + end +end diff --git a/lib/elixir_sense/providers/completion/reducers/bitstring.ex b/lib/elixir_sense/providers/completion/reducers/bitstring.ex new file mode 100644 index 00000000..c0b79769 --- /dev/null +++ b/lib/elixir_sense/providers/completion/reducers/bitstring.ex @@ -0,0 +1,39 @@ +defmodule ElixirSense.Providers.Completion.Reducers.Bitstring do + @moduledoc false + + alias ElixirSense.Core.Bitstring + alias ElixirSense.Core.Source + + @type bitstring_option :: %{ + type: :bitstring_option, + name: String.t() + } + + @doc """ + A reducer that adds suggestions of bitstring options. + """ + def add_bitstring_options(_hint, _env, _buffer_metadata, cursor_context, acc) do + prefix = cursor_context.text_before + + case Source.bitstring_options(prefix) do + candidate when not is_nil(candidate) -> + parsed = Bitstring.parse(candidate) + + list = + for option <- Bitstring.available_options(parsed), + candidate_part = candidate |> String.split("-") |> List.last(), + option_str = option |> Atom.to_string(), + String.starts_with?(option_str, candidate_part) do + %{ + name: option_str, + type: :bitstring_option + } + end + + {:cont, %{acc | result: acc.result ++ list}} + + _ -> + {:cont, acc} + end + end +end diff --git a/lib/elixir_sense/providers/completion/reducers/callbacks.ex b/lib/elixir_sense/providers/completion/reducers/callbacks.ex new file mode 100644 index 00000000..c1bdebfc --- /dev/null +++ b/lib/elixir_sense/providers/completion/reducers/callbacks.ex @@ -0,0 +1,161 @@ +defmodule ElixirSense.Providers.Completion.Reducers.Callbacks do + @moduledoc false + + alias ElixirSense.Core.Introspection + alias ElixirSense.Core.State + alias ElixirSense.Providers.Utils.Matcher + + @type callback :: %{ + type: :callback, + subtype: :callback | :macrocallback, + name: String.t(), + arity: non_neg_integer, + args: String.t(), + args_list: [String.t()], + origin: String.t(), + summary: String.t(), + spec: String.t(), + metadata: map + } + + @doc """ + A reducer that adds suggestions of callbacks. + """ + def add_callbacks( + hint, + env = %State.Env{module: module, typespec: nil}, + buffer_metadata, + context, + acc + ) + when module != nil do + text_before = context.text_before + + %State.Env{protocol: protocol, behaviours: behaviours} = env + + list = + Enum.flat_map(behaviours, fn + mod when is_atom(mod) and (protocol == nil or mod != elem(protocol, 0)) -> + mod_name = inspect(mod) + + if Map.has_key?(buffer_metadata.mods_funs_to_positions, {mod, nil, nil}) do + behaviour_callbacks = + buffer_metadata.specs + |> Enum.filter(fn {{behaviour_mod, _, arity}, %State.SpecInfo{kind: kind}} -> + behaviour_mod == mod and is_integer(arity) and kind in [:callback, :macrocallback] + end) + + for {{_, name, arity}, %State.SpecInfo{} = info} <- behaviour_callbacks, + hint == "" or def_prefix?(hint, List.last(info.specs)) or + Matcher.match?("#{name}", hint) do + def_info = buffer_metadata.mods_funs_to_positions[{env.module, name, arity}] + def_info_meta = if def_info, do: def_info.meta, else: %{} + meta = info.meta |> Map.merge(def_info_meta) + + %{ + type: :callback, + subtype: info.kind, + name: Atom.to_string(name), + arity: arity, + args: Enum.join(List.last(info.args), ", "), + args_list: List.last(info.args), + origin: mod_name, + summary: Introspection.extract_summary_from_docs(info.doc), + spec: List.last(info.specs), + metadata: meta + } + end + else + for %{ + name: name, + arity: arity, + kind: kind, + callback: spec, + signature: signature, + doc: doc, + metadata: metadata + } <- + Introspection.get_callbacks_with_docs(mod), + hint == "" or def_prefix?(hint, spec) or Matcher.match?("#{name}", hint) do + desc = Introspection.extract_summary_from_docs(doc) + + {args, args_list} = + if signature do + match_res = Regex.run(~r/.\(([^\)]*)\)/u, signature) + + unless match_res do + raise "unable to get arguments from #{inspect(signature)}" + end + + [_, args_str] = match_res + + args_list = + args_str + |> String.split(",") + |> Enum.map(&String.trim/1) + + args = + args_str + |> String.replace("\n", " ") + |> String.split(",") + |> Enum.map_join(", ", &String.trim/1) + + {args, args_list} + else + if arity == 0 do + {"", []} + else + args_list = for _ <- 1..arity, do: "term" + {Enum.join(args_list, ", "), args_list} + end + end + + def_info = buffer_metadata.mods_funs_to_positions[{env.module, name, arity}] + def_info_meta = if def_info, do: def_info.meta, else: %{} + meta = metadata |> Map.merge(def_info_meta) + + %{ + type: :callback, + subtype: kind, + name: Atom.to_string(name), + arity: arity, + args: args, + args_list: args_list, + origin: mod_name, + summary: desc, + spec: spec, + metadata: meta + } + end + end + + _ -> + [] + end) + + list = Enum.sort(list) + + cond do + Regex.match?(~r/\s(def|defmacro)\s+([_\p{Ll}\p{Lo}][\p{L}\p{N}_]*[?!]?)?$/u, text_before) -> + {:halt, %{acc | result: list}} + + env.function != nil -> + {:cont, acc} + + true -> + {:cont, %{acc | result: acc.result ++ list}} + end + end + + def add_callbacks(_hint, _env, _buffer_metadata, _context, acc) do + {:cont, acc} + end + + defp def_prefix?(hint, spec) do + if String.starts_with?(spec, "@macrocallback") do + String.starts_with?("defmacro", hint) + else + String.starts_with?("def", hint) + end + end +end diff --git a/lib/elixir_sense/providers/completion/reducers/complete_engine.ex b/lib/elixir_sense/providers/completion/reducers/complete_engine.ex new file mode 100644 index 00000000..6bb61d4a --- /dev/null +++ b/lib/elixir_sense/providers/completion/reducers/complete_engine.ex @@ -0,0 +1,137 @@ +defmodule ElixirSense.Providers.Completion.Reducers.CompleteEngine do + @moduledoc false + + alias ElixirSense.Core.Metadata + alias ElixirSense.Core.Source + alias ElixirSense.Core.State + alias ElixirSense.Providers.Completion.CompletionEngine + alias ElixirSense.Providers.Completion.Reducer + + @type t() :: CompletionEngine.t() + + @doc """ + A reducer that populates the context with the suggestions provided by + the `ElixirLS.Utils.CompletionEngine` module. + + The suggestions are grouped by type and saved in the context under the + `:complete_engine_suggestions_by_type` key and can be accessed by any reducer + that runs after. + + Available suggestions: + + * Modules + * Functions + * Macros + * Variables + * Module attributes + * Variable fields + + """ + def populate(hint, env, buffer_metadata, context, acc, opts \\ []) do + suggestions = + find_mods_funcs( + hint, + context.cursor_position, + env, + buffer_metadata, + context.text_before, + opts + ) + + suggestions_by_type = Enum.group_by(suggestions, & &1.type) + + {:cont, Reducer.put_context(acc, :complete_engine, suggestions_by_type)} + end + + @doc """ + A reducer that adds suggestions of existing modules. + + Note: requires populate/5. + """ + def add_modules(_hint, _env, _file_metadata, _context, acc) do + add_suggestions(:module, acc) + end + + @doc """ + A reducer that adds suggestions of existing functions. + + Note: requires populate/5. + """ + def add_functions(_hint, _env, _file_metadata, _context, acc) do + add_suggestions(:function, acc) + end + + @doc """ + A reducer that adds suggestions of existing macros. + + Note: requires populate/5. + """ + def add_macros(_hint, _env, _file_metadata, _context, acc) do + add_suggestions(:macro, acc) + end + + @doc """ + A reducer that adds suggestions of variable fields. + + Note: requires populate/5. + """ + def add_fields(_hint, _env, _file_metadata, _context, acc) do + add_suggestions(:field, acc) + end + + @doc """ + A reducer that adds suggestions of existing module attributes. + + Note: requires populate/5. + """ + def add_attributes(_hint, _env, _file_metadata, _context, acc) do + add_suggestions(:attribute, acc) + end + + @doc """ + A reducer that adds suggestions of existing variables. + + Note: requires populate/5. + """ + def add_variables(_hint, _env, _file_metadata, _context, acc) do + add_suggestions(:variable, acc) + end + + defp add_suggestions(type, acc) do + suggestions_by_type = Reducer.get_context(acc, :complete_engine) + list = Map.get(suggestions_by_type, type, []) + {:cont, %{acc | result: acc.result ++ list}} + end + + defp find_mods_funcs( + hint, + cursor_position, + %State.Env{ + module: module + } = env, + %Metadata{} = metadata, + text_before, + opts + ) do + hint = + case Source.get_v12_module_prefix(text_before, module) do + nil -> + hint + + module_string -> + # multi alias syntax detected + # prepend module prefix before running completion + prefix = module_string <> "." + prefix <> hint + end + + hint = + if String.starts_with?(hint, "__MODULE__") do + hint |> String.replace_leading("__MODULE__", inspect(module)) + else + hint + end + + CompletionEngine.complete(hint, env, metadata, cursor_position, opts) + end +end diff --git a/lib/elixir_sense/providers/completion/reducers/docs_snippets.ex b/lib/elixir_sense/providers/completion/reducers/docs_snippets.ex new file mode 100644 index 00000000..e843d5af --- /dev/null +++ b/lib/elixir_sense/providers/completion/reducers/docs_snippets.ex @@ -0,0 +1,42 @@ +defmodule ElixirSense.Providers.Completion.Reducers.DocsSnippets do + @moduledoc false + + alias ElixirSense.Providers.Plugins.Util + alias ElixirSense.Providers.Utils.Matcher + + # Format: + # {label, snippet, documentation, priority} + @module_attr_snippets [ + {~s(@doc """"""), ~s(@doc """\n$0\n"""), "Documents a function/macro/callback", 13}, + {"@doc false", "@doc false", "Marks this function/macro/callback as internal", 15}, + {~s(@moduledoc """"""), ~s(@moduledoc """\n$0\n"""), "Documents a module", 13}, + {"@moduledoc false", "@moduledoc false", "Marks this module as internal", 15}, + {~s(@typedoc """"""), ~s(@typedoc """\n$0\n"""), "Documents a type specification", 13}, + {"@typedoc false", "@typedoc false", "Marks this type specification as internal", 15} + ] + + @doc """ + A reducer that adds suggestions for @doc, @moduledoc and @typedoc. + """ + def add_snippets(hint, _env, _metadata, %{at_module_body?: true}, acc) do + list = + for {label, snippet, doc, priority} <- @module_attr_snippets, + Matcher.match?(label, hint) do + %{ + type: :generic, + kind: :snippet, + label: label, + snippet: Util.trim_leading_for_insertion(hint, snippet), + filter_text: String.replace_prefix(label, "@", "") |> String.split(" ") |> List.first(), + detail: "module attribute snippet", + documentation: doc, + priority: priority + } + end + + {:cont, %{acc | result: acc.result ++ Enum.sort(list)}} + end + + def add_snippets(_hint, _env, _metadata, _cursor_context, acc), + do: {:cont, acc} +end diff --git a/lib/elixir_sense/providers/completion/reducers/overridable.ex b/lib/elixir_sense/providers/completion/reducers/overridable.ex new file mode 100644 index 00000000..c7af1cf2 --- /dev/null +++ b/lib/elixir_sense/providers/completion/reducers/overridable.ex @@ -0,0 +1,96 @@ +defmodule ElixirSense.Providers.Completion.Reducers.Overridable do + @moduledoc false + + alias ElixirSense.Core.Introspection + alias ElixirSense.Core.State + alias ElixirSense.Providers.Utils.Matcher + + @doc """ + A reducer that adds suggestions of overridable functions. + """ + + def add_overridable( + hint, + env = %State.Env{typespec: nil, module: module}, + metadata, + _cursor_context, + acc + ) + when not is_nil(module) do + %State.Env{protocol: protocol, behaviours: behaviours, module: module} = env + + # overridable behaviour callbacks are returned by Reducers.Callbacks + behaviour_callbacks = + Enum.flat_map(behaviours, fn + mod when is_atom(mod) and (protocol == nil or mod != elem(protocol, 0)) -> + for %{ + name: name, + arity: arity + } <- + Introspection.get_callbacks_with_docs(mod) do + {name, arity} + end + + _ -> + [] + end) + + # no need to care of default args here + # only the max arity version can be overridden + list = + for {{^module, name, arity}, %State.ModFunInfo{overridable: {true, origin}} = info} + when is_integer(arity) <- metadata.mods_funs_to_positions, + def_prefix?(hint, info.type) or Matcher.match?("#{name}", hint), + {name, arity} not in behaviour_callbacks do + spec = + case metadata.specs[{module, name, arity}] do + %State.SpecInfo{specs: specs} -> specs |> Enum.join("\n") + nil -> "" + end + + args_list = + info.params + |> List.last() + |> Enum.with_index() + |> Enum.map(&Introspection.param_to_var/1) + + args = args_list |> Enum.join(", ") + + subtype = + case State.ModFunInfo.get_category(info) do + :function -> :callback + :macro -> :macrocallback + end + + %{ + type: :callback, + subtype: subtype, + name: Atom.to_string(name), + arity: arity, + args: args, + args_list: args_list, + origin: inspect(origin), + summary: Introspection.extract_summary_from_docs(info.doc), + metadata: info.meta, + spec: spec + } + end + + {:cont, %{acc | result: acc.result ++ Enum.sort(list)}} + end + + def add_overridable(_hint, %State.Env{}, _metadata, _cursor_context, acc), + do: {:cont, acc} + + defp def_prefix?(hint, type) when type in [:defmacro, :defmacrop] do + String.starts_with?("defmacro", hint) + end + + defp def_prefix?(hint, type) when type in [:defguard, :defguardp] do + String.starts_with?("defguard", hint) + end + + defp def_prefix?(hint, type) when type in [:def, :defp, :defdelegate] do + String.starts_with?("def", hint) + end +end diff --git a/lib/elixir_sense/providers/completion/reducers/params.ex b/lib/elixir_sense/providers/completion/reducers/params.ex new file mode 100644 index 00000000..4e9aeeb2 --- /dev/null +++ b/lib/elixir_sense/providers/completion/reducers/params.ex @@ -0,0 +1,80 @@ +defmodule ElixirSense.Providers.Completion.Reducers.Params do + @moduledoc false + + alias ElixirSense.Core.Binding + alias ElixirSense.Core.Introspection + alias ElixirSense.Core.Metadata + alias ElixirSense.Core.Source + alias ElixirSense.Core.TypeInfo + alias ElixirSense.Providers.Utils.Matcher + + @type param_option :: %{ + type: :param_option, + name: String.t(), + origin: String.t(), + type_spec: String.t(), + doc: String.t(), + expanded_spec: String.t() + } + + @doc """ + A reducer that adds suggestions of keyword list options. + """ + def add_options(hint, env, buffer_metadata, cursor_context, acc) do + prefix = cursor_context.text_before + + binding_env = Binding.from_env(env, buffer_metadata) + + %Metadata{mods_funs_to_positions: mods_funs, types: metadata_types} = buffer_metadata + + with %{ + candidate: {mod, fun}, + elixir_prefix: elixir_prefix, + npar: npar + } <- + Source.which_func(prefix, binding_env), + {mod, fun, true, :mod_fun} <- + Introspection.actual_mod_fun( + {mod, fun}, + env, + mods_funs, + metadata_types, + {1, 1}, + not elixir_prefix + ) do + list = + if Code.ensure_loaded?(mod) do + TypeInfo.extract_param_options(mod, fun, npar) + |> Kernel.++(TypeInfo.extract_param_options(mod, :"MACRO-#{fun}", npar + 1)) + |> options_to_suggestions(mod) + |> Enum.filter(&Matcher.match?(&1.name, hint)) + else + # TODO metadata? + [] + end + + {:cont, %{acc | result: acc.result ++ list}} + else + _ -> + {:cont, acc} + end + end + + defp options_to_suggestions(options, original_module) do + Enum.map(options, fn + {mod, name, type} -> + TypeInfo.get_type_info(mod, type, original_module) + |> Map.merge(%{type: :param_option, name: name |> Atom.to_string()}) + + {mod, name} -> + %{ + doc: "", + expanded_spec: "", + name: name |> Atom.to_string(), + origin: inspect(mod), + type: :param_option, + type_spec: "" + } + end) + end +end diff --git a/lib/elixir_sense/providers/completion/reducers/protocol.ex b/lib/elixir_sense/providers/completion/reducers/protocol.ex new file mode 100644 index 00000000..8915e6d6 --- /dev/null +++ b/lib/elixir_sense/providers/completion/reducers/protocol.ex @@ -0,0 +1,83 @@ +defmodule ElixirSense.Providers.Completion.Reducers.Protocol do + @moduledoc false + + alias ElixirSense.Core.Introspection + alias ElixirSense.Core.State + alias ElixirSense.Providers.Utils.Matcher + + @type protocol_function :: %{ + type: :protocol_function, + name: String.t(), + arity: non_neg_integer, + args: String.t(), + args_list: [String.t()], + origin: String.t(), + summary: String.t(), + spec: String.t(), + metadata: map + } + + @doc """ + A reducer that adds suggestions of protocol functions. + """ + def add_functions(_hint, %State.Env{typespec: {_f, _a}}, _metadata, _cursor_context, acc), + do: {:cont, acc} + + def add_functions(_hint, %State.Env{module: nil}, _metadata, _cursor_context, acc), + do: {:cont, acc} + + def add_functions(_hint, %State.Env{protocol: nil}, _metadata, _cursor_context, acc), + do: {:cont, acc} + + def add_functions(hint, env, buffer_metadata, _cursor_context, acc) do + %State.Env{protocol: {protocol, _implementations}} = env + + mod_name = inspect(protocol) + + list = + if Map.has_key?(buffer_metadata.mods_funs_to_positions, {protocol, nil, nil}) do + behaviour_callbacks = + buffer_metadata.specs + |> Enum.filter(fn {{mod, _, arity}, %State.SpecInfo{kind: kind}} -> + mod == protocol and is_integer(arity) and kind in [:callback] + end) + + for {{_, name, arity}, %State.SpecInfo{} = info} <- behaviour_callbacks, + hint == "" or String.starts_with?("def", hint) or Matcher.match?("#{name}", hint) do + %State.ModFunInfo{} = + def_info = + buffer_metadata.mods_funs_to_positions |> Map.fetch!({protocol, name, arity}) + + %{ + type: :protocol_function, + name: Atom.to_string(name), + arity: arity, + args: Enum.join(List.last(info.args), ", "), + args_list: List.last(info.args), + origin: mod_name, + summary: Introspection.extract_summary_from_docs(def_info.doc), + spec: List.last(info.specs), + metadata: def_info.meta + } + end + else + for {{name, arity}, {_type, args, docs, metadata, spec}} <- + Introspection.module_functions_info(protocol), + hint == "" or String.starts_with?("def", hint) or Matcher.match?("#{name}", hint) do + %{ + type: :protocol_function, + name: Atom.to_string(name), + arity: arity, + args: args |> Enum.join(", "), + args_list: args, + origin: inspect(protocol), + summary: docs, + metadata: metadata, + spec: spec + } + end + end + + {:cont, %{acc | result: acc.result ++ Enum.sort(list)}} + end +end diff --git a/lib/elixir_sense/providers/completion/reducers/returns.ex b/lib/elixir_sense/providers/completion/reducers/returns.ex new file mode 100644 index 00000000..54f351ab --- /dev/null +++ b/lib/elixir_sense/providers/completion/reducers/returns.ex @@ -0,0 +1,97 @@ +defmodule ElixirSense.Providers.Completion.Reducers.Returns do + @moduledoc false + + alias ElixirSense.Core.Introspection + alias ElixirSense.Core.Metadata + alias ElixirSense.Core.State + + @type return :: %{ + type: :return, + description: String.t(), + spec: String.t(), + snippet: String.t() + } + + @doc """ + A reducer that adds suggestions of possible return values. + """ + def add_returns( + "" = _hint, + %State.Env{function: {fun, arity}} = env, + buffer_metadata, + _context, + acc + ) do + %State.Env{module: current_module, behaviours: behaviours, protocol: protocol} = env + %Metadata{specs: specs} = buffer_metadata + + spec_returns = + case specs[{current_module, fun, arity}] do + nil -> + [] + + %State.SpecInfo{specs: info_specs} -> + for spec <- info_specs, + {:ok, {:@, _, [{_, _, [quoted]}]}} <- [Code.string_to_quoted(spec)], + return <- Introspection.get_returns_from_spec_ast(quoted) do + format_return(return) + end + end + + callbacks = + for mod <- behaviours, + protocol == nil or mod != elem(protocol, 0) do + case specs[{mod, fun, arity}] do + nil -> + for return <- Introspection.get_returns_from_callback(mod, fun, arity) do + format_return(return) + end + + %State.SpecInfo{specs: info_specs} -> + for spec <- info_specs, + {:ok, {:@, _, [{_, _, [quoted]}]}} <- [Code.string_to_quoted(spec)], + return <- Introspection.get_returns_from_spec_ast(quoted) do + format_return(return) + end + end + end + |> List.flatten() + + protocol_functions = + case protocol do + {proto, _implementations} -> + case specs[{proto, fun, arity}] do + nil -> + for return <- Introspection.get_returns_from_callback(proto, fun, arity) do + format_return(return) + end + + %State.SpecInfo{specs: info_specs} -> + for spec <- info_specs, + {:ok, {:@, _, [{:callback, _, [quoted]}]}} <- [Code.string_to_quoted(spec)], + return <- Introspection.get_returns_from_spec_ast(quoted) do + format_return(return) + end + end + + nil -> + [] + end + + list = callbacks ++ protocol_functions ++ spec_returns + {:cont, %{acc | result: acc.result ++ list}} + end + + def add_returns(_hint, _env, _buffer_metadata, _context, acc) do + {:cont, acc} + end + + defp format_return(return) do + %{ + type: :return, + description: return.description, + spec: return.spec, + snippet: return.snippet + } + end +end diff --git a/lib/elixir_sense/providers/completion/reducers/struct.ex b/lib/elixir_sense/providers/completion/reducers/struct.ex new file mode 100644 index 00000000..6de24009 --- /dev/null +++ b/lib/elixir_sense/providers/completion/reducers/struct.ex @@ -0,0 +1,155 @@ +defmodule ElixirSense.Providers.Completion.Reducers.Struct do + @moduledoc false + + alias ElixirSense.Core.Binding + alias ElixirSense.Core.Introspection + alias ElixirSense.Core.Metadata + alias ElixirSense.Core.Source + alias ElixirSense.Core.State + alias ElixirSense.Providers.Utils.Matcher + + @type field :: %{ + type: :field, + subtype: :struct_field | :map_key, + name: String.t(), + origin: String.t() | nil, + call?: boolean, + type_spec: String.t() | nil + } + + @doc """ + A reducer that adds suggestions of struct fields. + """ + def add_fields(hint, env, buffer_metadata, context, acc) do + text_before = context.text_before + + case find_struct_fields(hint, text_before, env, buffer_metadata) do + {[], _} -> + {:cont, acc} + + {fields, nil} -> + {:halt, %{acc | result: fields}} + + {fields, :maybe_struct_update} -> + reducers = [ + :populate_complete_engine, + :modules, + :functions, + :macros, + :variables, + :attributes + ] + + {:cont, %{acc | result: fields, reducers: reducers}} + end + end + + defp find_struct_fields(hint, text_before, env, buffer_metadata) do + %State.Env{ + module: module, + vars: vars, + attributes: attributes, + aliases: aliases + } = env + + %Metadata{ + structs: structs, + mods_funs_to_positions: mods_funs, + types: metadata_types, + specs: specs + } = buffer_metadata + + env = %ElixirSense.Core.Binding{ + attributes: attributes, + variables: vars, + structs: structs, + functions: env.functions, + macros: env.macros, + current_module: module, + specs: specs, + types: metadata_types, + mods_funs: mods_funs + } + + case Source.which_struct(text_before, module) do + {type, fields_so_far, elixir_prefix, var} -> + type = + case {type, elixir_prefix} do + {{:atom, mod}, false} -> + # which_struct returns not expanded aliases + {:atom, Introspection.expand_alias(mod, aliases)} + + _ -> + type + end + + type = Binding.expand(env, {:struct, [], type, var}) + + result = get_fields(buffer_metadata, type, hint, fields_so_far) + {result, if(fields_so_far == [], do: :maybe_struct_update)} + + {:map, fields_so_far, var} -> + var = Binding.expand(env, var) + + result = get_fields(buffer_metadata, var, hint, fields_so_far) + {result, if(fields_so_far == [], do: :maybe_struct_update)} + + _ -> + {[], nil} + end + end + + defp get_fields(metadata, {:map, fields, _}, hint, fields_so_far) do + expand_map_field_access(metadata, fields, hint, :map, fields_so_far) + end + + defp get_fields(metadata, {:struct, fields, type, _}, hint, fields_so_far) do + expand_map_field_access(metadata, fields, hint, {:struct, type}, fields_so_far) + end + + defp get_fields(_, _, _hint, _fields_so_far), do: [] + + defp expand_map_field_access(metadata, fields, hint, type, fields_so_far) do + {subtype, origin, types} = + case type do + {:struct, {:atom, mod}} -> + types = ElixirSense.Providers.Utils.Field.get_field_types(metadata, mod, true) + + {:struct_field, inspect(mod), types} + + {:struct, nil} -> + {:struct_field, nil, %{}} + + :map -> + {:map_key, nil, %{}} + end + + for {key, _value} when is_atom(key) <- fields, + key not in fields_so_far, + key_str = Atom.to_string(key), + Matcher.match?(key_str, hint) do + spec = + case types[key] do + nil -> + case key do + :__struct__ -> origin || "atom()" + :__exception__ -> "true" + _ -> nil + end + + some -> + Introspection.to_string_with_parens(some) + end + + %{ + type: :field, + name: key_str, + subtype: subtype, + origin: origin, + call?: false, + type_spec: spec + } + end + |> Enum.sort_by(& &1.name) + end +end diff --git a/lib/elixir_sense/providers/completion/reducers/type_specs.ex b/lib/elixir_sense/providers/completion/reducers/type_specs.ex new file mode 100644 index 00000000..176b4eae --- /dev/null +++ b/lib/elixir_sense/providers/completion/reducers/type_specs.ex @@ -0,0 +1,180 @@ +defmodule ElixirSense.Providers.Completion.Reducers.TypeSpecs do + @moduledoc false + + alias ElixirSense.Core.Binding + alias ElixirSense.Core.Introspection + alias ElixirSense.Core.Metadata + alias ElixirSense.Core.Source + alias ElixirSense.Core.State + alias ElixirSense.Core.TypeInfo + alias ElixirSense.Providers.Utils.Matcher + + @type type_spec :: %{ + type: :type_spec, + name: String.t(), + arity: non_neg_integer, + origin: String.t() | nil, + args_list: list(String.t()), + spec: String.t(), + doc: String.t(), + signature: String.t(), + metadata: map + } + + @doc """ + A reducer that adds suggestions of type specs. + """ + + # We only list type specs when inside typespec scope + def add_types(hint, env, file_metadata, %{at_module_body?: _}, acc) do + if match?({_, _}, env.typespec) do + %State.Env{ + aliases: aliases, + module: module + } = env + + %Metadata{mods_funs_to_positions: mods_funs, types: metadata_types} = file_metadata + + binding_env = Binding.from_env(env, file_metadata) + + {mod, hint} = + hint + |> Source.split_module_and_hint(module, aliases) + |> expand(binding_env, aliases) + + list = + find_typespecs_for_mod_and_hint( + {mod, hint}, + env, + mods_funs, + metadata_types + ) + |> Kernel.++(find_builtin_types({mod, hint})) + + {:cont, %{acc | result: acc.result ++ list}} + else + {:cont, acc} + end + end + + def add_types(_hint, _env, _buffer_metadata, _context, acc) do + {:cont, acc} + end + + defp expand({{kind, _} = type, hint}, env, aliases) when kind in [:attribute, :variable] do + # TODO Binding should return expanded aliases + case Binding.expand(env, type) do + {:atom, module} -> {Introspection.expand_alias(module, aliases), hint} + _ -> {nil, ""} + end + end + + defp expand({type, hint}, _env, _aliases) do + {type, hint} + end + + defp find_typespecs_for_mod_and_hint( + {mod, hint}, + env, + mods_funs, + metadata_types + ) do + # alias already expanded by Source.split_module_and_hint + case Introspection.actual_module(mod, env, mods_funs, false) do + {actual_mod, true} -> + find_module_types(actual_mod, {mod, hint}, metadata_types, env.module) + + {nil, false} -> + find_module_types(env.module, {mod, hint}, metadata_types, env.module) + + {_, false} -> + [] + end + end + + defp find_builtin_types({nil, hint}) do + TypeInfo.find_all_builtin(&Matcher.match?("#{&1.name}", hint)) + |> Enum.map(&type_info_to_suggestion(&1, nil)) + |> Enum.sort_by(fn %{name: name, arity: arity} -> {name, arity} end) + end + + defp find_builtin_types({_mod, _hint}), do: [] + + defp find_module_types(actual_mod, {mod, hint}, metadata_types, module) do + find_metadata_types(actual_mod, {mod, hint}, metadata_types, module) + |> Kernel.++(TypeInfo.find_all(actual_mod, &Matcher.match?("#{&1.name}", hint))) + |> Enum.map(&type_info_to_suggestion(&1, actual_mod)) + |> Enum.uniq_by(fn %{name: name, arity: arity} -> {name, arity} end) + |> Enum.sort_by(fn %{name: name, arity: arity} -> {name, arity} end) + end + + defp find_metadata_types(actual_mod, {mod, hint}, metadata_types, module) do + # local types are hoisted, no need to check position + include_private = mod == nil and actual_mod == module + + for {{^actual_mod, type, arity}, type_info} when is_integer(arity) <- metadata_types, + type |> Atom.to_string() |> Matcher.match?(hint), + include_private or type_info.kind != :typep, + do: type_info + end + + defp type_info_to_suggestion(type_info, module) do + origin = if module, do: inspect(module) + + case type_info do + %ElixirSense.Core.State.TypeInfo{args: [args]} -> + args_stringified = Enum.join(args, ", ") + + spec = + case type_info.kind do + :opaque -> "@opaque #{type_info.name}(#{args_stringified})" + _ -> List.last(type_info.specs) + end + + %{ + type: :type_spec, + name: type_info.name |> Atom.to_string(), + arity: length(args), + args_list: args, + signature: "#{type_info.name}(#{args_stringified})", + origin: origin, + doc: Introspection.extract_summary_from_docs(type_info.doc), + spec: spec, + metadata: type_info.meta + } + + _ -> + args_list = + if type_info.signature do + part = + type_info.signature + |> String.split("(") + |> Enum.at(1) + + if part do + part + |> String.split(")") + |> Enum.at(0) + |> String.split(",") + |> Enum.map(&String.trim/1) + else + [] + end + else + [] + end + + %{ + type: :type_spec, + name: type_info.name |> Atom.to_string(), + arity: type_info.arity, + args_list: args_list, + signature: type_info.signature, + origin: origin, + doc: type_info.doc, + spec: type_info.spec, + metadata: type_info.metadata + } + end + end +end diff --git a/lib/elixir_sense/providers/completion/suggestion.ex b/lib/elixir_sense/providers/completion/suggestion.ex new file mode 100644 index 00000000..4a37d7de --- /dev/null +++ b/lib/elixir_sense/providers/completion/suggestion.ex @@ -0,0 +1,277 @@ +defmodule ElixirSense.Providers.Completion.Suggestion do + @moduledoc """ + Provider responsible for finding suggestions for auto-completing. + + It provides suggestions based on a list of pre-defined reducers. + + ## Reducers + + A reducer is a function with the following spec: + + @spec reducer( + String.t(), + String.t(), + State.Env.t(), + Metadata.t(), + acc() + ) :: {:cont | :halt, acc()} + + ## Examples + + Adding suggestions: + + def my_reducer(hint, prefix, env, buffer_metadata, acc) do + suggestions = ... + {:cont, %{acc | result: acc.result ++ suggestions}} + end + + Defining the only set of suggestions to be provided: + + def my_reducer(hint, prefix, env, buffer_metadata, acc) do + suggestions = ... + {:halt, %{acc | result: suggestions}} + end + + Defining a list of suggestions to be provided and allow an extra + limited set of additional reducers to run next: + + def my_reducer(hint, prefix, env, buffer_metadata, acc) do + suggestions = ... + {:cont, %{acc | result: fields, reducers: [:populate_complete_engine, :variables]}} + end + """ + + alias ElixirSense.Core.Metadata + alias ElixirSense.Providers.Plugins.ModuleStore + alias ElixirSense.Core.State + alias ElixirSense.Core.Parser + alias ElixirSense.Core.Source + alias ElixirSense.Providers.Completion.Reducers + + @type generic :: %{ + type: :generic, + label: String.t(), + detail: String.t() | nil, + documentation: String.t() | nil, + insert_text: String.t() | nil, + filter_text: String.t() | nil, + snippet: String.t() | nil, + priority: integer() | nil, + kind: atom(), + command: map() + } + + @type suggestion :: + generic() + | Reducers.CompleteEngine.t() + | Reducers.Struct.field() + | Reducers.Returns.return() + | Reducers.Callbacks.callback() + | Reducers.Protocol.protocol_function() + | Reducers.Params.param_option() + | Reducers.TypeSpecs.type_spec() + | Reducers.Bitstring.bitstring_option() + + @type acc :: %{result: [suggestion], reducers: [atom], context: map} + @type cursor_context :: %{ + text_before: String.t(), + text_after: String.t(), + at_module_body?: boolean(), + cursor_position: {pos_integer, pos_integer} + } + + @reducers [ + structs_fields: &Reducers.Struct.add_fields/5, + returns: &Reducers.Returns.add_returns/5, + callbacks: &Reducers.Callbacks.add_callbacks/5, + protocol_functions: &Reducers.Protocol.add_functions/5, + overridable: &Reducers.Overridable.add_overridable/5, + param_options: &Reducers.Params.add_options/5, + typespecs: &Reducers.TypeSpecs.add_types/5, + populate_complete_engine: &Reducers.CompleteEngine.populate/6, + variables: &Reducers.CompleteEngine.add_variables/5, + modules: &Reducers.CompleteEngine.add_modules/5, + functions: &Reducers.CompleteEngine.add_functions/5, + macros: &Reducers.CompleteEngine.add_macros/5, + variable_fields: &Reducers.CompleteEngine.add_fields/5, + attributes: &Reducers.CompleteEngine.add_attributes/5, + docs_snippets: &Reducers.DocsSnippets.add_snippets/5, + bitstring_options: &Reducers.Bitstring.add_bitstring_options/5 + ] + + @add_opts_for [:populate_complete_engine] + + @spec suggestions(String.t(), pos_integer, pos_integer, keyword()) :: [Suggestion.suggestion()] + def suggestions(code, line, column, options \\ []) do + hint = Source.prefix(code, line, column) + + metadata = + Keyword.get_lazy(options, :metadata, fn -> + Parser.parse_string(code, true, true, {line, column}) + end) + + {text_before, text_after} = Source.split_at(code, line, column) + + metadata = + maybe_fix_autocomple_on_cursor( + metadata, + text_before, + text_after, + {line, column} + ) + + env = + Metadata.get_env(metadata, {line, column}) + |> Metadata.add_scope_vars( + metadata, + {line, column}, + &(to_string(&1.name) != hint) + ) + + # if variable is rebound then in env there are many variables with the same name + # find the one defined closest to cursor + vars = + env.vars + |> Enum.group_by(fn %State.VarInfo{name: name} -> name end) + |> Enum.map(fn {_name, list} -> + list + |> Enum.max_by(fn + %State.VarInfo{positions: [_position]} -> + # variable is being defined - it's not a good candidate + {0, 0} + + %State.VarInfo{positions: positions} -> + Enum.min(positions) + end) + end) + + env = %{env | vars: vars} + + module_store = ModuleStore.build() + + cursor_context = %{ + cursor_position: {line, column}, + text_before: text_before, + text_after: text_after, + at_module_body?: Metadata.at_module_body?(env) + } + + find(hint, env, metadata, cursor_context, module_store, options) + end + + # Provides a last attempt to fix a couple of parse errors that + # commonly appear when working with autocomplete on functions + # without parens. + # + # Note: This will be removed after refactoring the parser to + # allow unparsable nodes in the AST. + defp maybe_fix_autocomple_on_cursor(%Metadata{error: nil} = meta, _, _, _) do + meta + end + + defp maybe_fix_autocomple_on_cursor(metadata, text_before, text_after, {line, column}) do + # Fix incomplete call, e.g. cursor after `var.` + fix_incomplete_call = fn text_before, text_after -> + if String.ends_with?(text_before, ".") do + text_before <> "__fake_call__" <> text_after + end + end + + # Fix incomplete kw, e.g. cursor after `option1: 1,` + fix_incomplete_kw = fn text_before, text_after -> + if Regex.match?(~r/\,\s*$/u, text_before) do + text_before <> "__fake_key__: :__fake_value__" <> text_after + end + end + + # Fix incomplete kw key, e.g. cursor after `option1: 1, opt` + fix_incomplete_kw_key = fn text_before, text_after -> + if Regex.match?(~r/\,\s*([\p{L}_][\p{L}\p{N}_@]*[?!]?)?$/u, text_before) do + text_before <> ": :__fake_value__" <> text_after + end + end + + # TODO this may no longer be needed + # only fix_incomplete_call has some tests depending on it + fixers = [ + fix_incomplete_call, + fix_incomplete_kw, + fix_incomplete_kw_key + ] + + Enum.reduce_while(fixers, nil, fn fun, _ -> + new_buffer = fun.(text_before, text_after) + + with true <- new_buffer != nil, + meta <- Parser.parse_string(new_buffer, false, true, {line, column}), + %Metadata{error: error} <- meta, + true <- error == nil do + {:halt, meta} + else + _ -> + {:cont, metadata} + end + end) + end + + @doc """ + Finds all suggestions for a hint based on context information. + """ + @spec find(String.t(), State.Env.t(), Metadata.t(), cursor_context, ModuleStore.t(), keyword()) :: + [suggestion()] + def find( + hint, + env, + buffer_metadata, + cursor_context, + %{plugins: plugins} = module_store, + opts \\ [] + ) do + reducers = + plugins + |> Enum.filter(&function_exported?(&1, :reduce, 5)) + |> Enum.map(fn module -> + {module, &module.reduce/5} + end) + |> Enum.concat(@reducers) + |> maybe_add_opts(opts) + + context = + plugins + |> Enum.filter(&function_exported?(&1, :setup, 1)) + |> Enum.reduce(%{module_store: module_store}, fn plugin, context -> + plugin.setup(context) + end) + + acc = %{result: [], reducers: Keyword.keys(reducers), context: context} + + %{result: result} = + Enum.reduce_while(reducers, acc, fn {key, fun}, acc -> + if key in acc.reducers do + fun.(hint, env, buffer_metadata, cursor_context, acc) + else + {:cont, acc} + end + end) + + for item <- result do + plugins + |> Enum.filter(&function_exported?(&1, :decorate, 1)) + |> Enum.reduce(item, fn module, item -> module.decorate(item) end) + end + end + + defp maybe_add_opts(reducers, opts) do + Enum.map(reducers, fn {name, reducer} -> + if name in @add_opts_for do + {name, reducer_with_opts(reducer, opts)} + else + {name, reducer} + end + end) + end + + defp reducer_with_opts(fun, opts) do + fn a, b, c, d, e -> fun.(a, b, c, d, e, opts) end + end +end diff --git a/lib/elixir_sense/providers/definition/locator.ex b/lib/elixir_sense/providers/definition/locator.ex new file mode 100644 index 00000000..90a420f2 --- /dev/null +++ b/lib/elixir_sense/providers/definition/locator.ex @@ -0,0 +1,291 @@ +# This code has originally been a part of https://github.com/elixir-lsp/elixir_sense + +# Copyright (c) 2017 Marlus Saraiva +# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +defmodule ElixirSense.Providers.Definition.Locator do + @moduledoc """ + Provides a function to find out where symbols are defined. + + Currently finds definition of modules, functions and macros, + typespecs, variables and attributes. + """ + + alias ElixirSense.Core.Binding + alias ElixirSense.Core.Introspection + alias ElixirSense.Core.Metadata + alias ElixirSense.Core.State + alias ElixirSense.Core.State.ModFunInfo + alias ElixirSense.Core.State.TypeInfo + alias ElixirSense.Core.State.VarInfo + alias ElixirSense.Core.Source + alias ElixirSense.Core.SurroundContext + alias ElixirSense.Providers.Location + alias ElixirSense.Core.Parser + + alias ElixirSense.Providers.Plugins.Phoenix.Scope + alias ElixirSense.Core.Normalized.Code, as: NormalizedCode + + def definition(code, line, column, options \\ []) do + case NormalizedCode.Fragment.surround_context(code, {line, column}) do + :none -> + nil + + context -> + metadata = + Keyword.get_lazy(options, :metadata, fn -> + Parser.parse_string(code, true, true, {line, column}) + end) + + env = + Metadata.get_env(metadata, {line, column}) + |> Metadata.add_scope_vars(metadata, {line, column}) + + find( + context, + env, + metadata + ) + end + end + + @doc """ + Finds out where a module, function, macro or variable was defined. + """ + @spec find( + any(), + State.Env.t(), + Metadata.t() + ) :: %Location{} | nil + def find( + context, + %State.Env{ + module: module, + vars: vars, + attributes: attributes + } = env, + metadata + ) do + binding_env = Binding.from_env(env, metadata) + + type = SurroundContext.to_binding(context.context, module) + + case type do + nil -> + nil + + {:keyword, _} -> + nil + + {:variable, variable} -> + var_info = + vars + |> Enum.find(fn + %VarInfo{name: name, positions: positions} -> + name == variable and context.begin in positions + end) + + if var_info != nil do + {definition_line, definition_column} = Enum.min(var_info.positions) + + %Location{type: :variable, file: nil, line: definition_line, column: definition_column} + else + find_function_or_module( + {nil, variable}, + context, + env, + metadata, + binding_env + ) + end + + {:attribute, attribute} -> + attribute_info = + Enum.find(attributes, fn + %State.AttributeInfo{name: name} -> name == attribute + end) + + if attribute_info != nil do + %State.AttributeInfo{positions: [{line, column} | _]} = attribute_info + %Location{type: :attribute, file: nil, line: line, column: column} + end + + {module, function} -> + find_function_or_module( + {module, function}, + context, + env, + metadata, + binding_env + ) + end + end + + defp find_function_or_module( + target, + context, + env, + metadata, + binding_env, + visited \\ [] + ) do + unless target in visited do + do_find_function_or_module( + target, + context, + env, + metadata, + binding_env, + [target | visited] + ) + end + end + + defp do_find_function_or_module( + {{kind, _} = type, function}, + context, + env, + metadata, + binding_env, + visited + ) + when kind in [:attribute, :variable] do + case Binding.expand(binding_env, type) do + {:atom, module} -> + do_find_function_or_module( + {{:atom, module}, function}, + context, + env, + metadata, + binding_env, + visited + ) + + _ -> + nil + end + end + + defp do_find_function_or_module( + {nil, :super}, + context, + %State.Env{function: {function, arity}, module: module} = env, + metadata, + binding_env, + visited + ) do + case metadata.mods_funs_to_positions[{module, function, arity}] do + %ModFunInfo{overridable: {true, origin}} -> + # overridable function is most likely defined by __using__ macro + do_find_function_or_module( + {{:atom, origin}, :__using__}, + context, + env, + metadata, + binding_env, + visited + ) + + _ -> + nil + end + end + + defp do_find_function_or_module( + {module, function}, + context, + env, + metadata, + _binding_env, + _visited + ) do + m = get_module(module, context, env, metadata) + + case {m, function} + |> Introspection.actual_mod_fun( + env, + metadata.mods_funs_to_positions, + metadata.types, + context.begin, + true + ) do + {_, _, false, _} -> + nil + + {mod, fun, true, :mod_fun} -> + {line, column} = context.end + call_arity = Metadata.get_call_arity(metadata, mod, fun, line, column) || :any + + fn_definition = + Location.get_function_position_using_metadata( + mod, + fun, + call_arity, + metadata.mods_funs_to_positions + ) + + case fn_definition do + nil -> + Location.find_mod_fun_source(mod, fun, call_arity) + + %ModFunInfo{positions: positions} = mi -> + # for simplicity take last position here as positions are reversed + {line, column} = positions |> Enum.at(-1) + + %Location{ + file: nil, + type: ModFunInfo.get_category(mi), + line: line, + column: column + } + end + + {mod, fun, true, :type} -> + {line, column} = context.end + call_arity = Metadata.get_call_arity(metadata, mod, fun, line, column) || :any + + type_definition = + Location.get_type_position_using_metadata(mod, fun, call_arity, metadata.types) + + case type_definition do + nil -> + Location.find_type_source(mod, fun, call_arity) + + %TypeInfo{positions: positions} -> + # for simplicity take last position here as positions are reversed + {line, column} = positions |> Enum.at(-1) + + %Location{ + file: nil, + type: :typespec, + line: line, + column: column + } + end + end + end + + defp get_module(module, %{end: {line, col}}, env, metadata) do + with {true, module} <- get_phoenix_module(module, env), + true <- Introspection.elixir_module?(module) do + text_before = Source.text_before(metadata.source, line, col) + + case Scope.within_scope(text_before) do + {false, _} -> + module + + {true, scope_alias} -> + Module.concat(scope_alias, module) + end + end + end + + defp get_phoenix_module(module, env) do + case {Phoenix.Router in env.requires, module} do + {true, {:atom, module}} -> {true, module} + {false, {:atom, module}} -> module + _ -> nil + end + end +end diff --git a/lib/elixir_sense/providers/hover/docs.ex b/lib/elixir_sense/providers/hover/docs.ex new file mode 100644 index 00000000..d2317816 --- /dev/null +++ b/lib/elixir_sense/providers/hover/docs.ex @@ -0,0 +1,676 @@ +defmodule ElixirSense.Providers.Hover.Docs do + alias ElixirSense.Core.Binding + alias ElixirSense.Core.BuiltinAttributes + alias ElixirSense.Core.BuiltinFunctions + alias ElixirSense.Core.BuiltinTypes + require ElixirSense.Core.Introspection, as: Introspection + alias ElixirSense.Core.Metadata + alias ElixirSense.Core.Normalized.Code, as: NormalizedCode + alias ElixirSense.Core.Normalized.Typespec + alias ElixirSense.Core.ReservedWords + alias ElixirSense.Core.State + alias ElixirSense.Core.SurroundContext + alias ElixirSense.Core.State.ModFunInfo + alias ElixirSense.Core.State.VarInfo + alias ElixirSense.Core.TypeInfo + alias ElixirSense.Core.Parser + + @type markdown :: String.t() + + @type module_doc :: %{kind: :module, docs: markdown, metadata: map, module: module()} + + @type function_doc :: %{ + kind: :function | :macro, + module: module(), + function: atom(), + arity: non_neg_integer(), + args: list(String.t()), + metadata: map(), + specs: list(String.t()), + docs: markdown() + } + + @type type_doc :: %{ + kind: :type, + module: module() | nil, + type: atom(), + arity: non_neg_integer(), + args: list(String.t()), + metadata: map(), + spec: String.t(), + docs: markdown() + } + + @type variable_doc :: %{ + name: atom(), + kind: :variable + } + + @type attribute_doc :: %{ + name: atom(), + kind: :attribute, + docs: markdown() + } + + @type keyword_doc :: %{ + name: atom(), + kind: :attribute, + docs: markdown() + } + + @type doc :: module_doc | function_doc | type_doc | variable_doc | attribute_doc | keyword_doc + + @builtin_functions BuiltinFunctions.all() + |> Enum.map(&elem(&1, 0)) + |> Kernel.--([:exception, :message]) + + def docs(code, line, column, options \\ []) do + case NormalizedCode.Fragment.surround_context(code, {line, column}) do + :none -> + nil + + %{begin: begin_pos, end: end_pos} = context -> + metadata = + Keyword.get_lazy(options, :metadata, fn -> + Parser.parse_string(code, true, true, {line, column}) + end) + + env = Metadata.get_env(metadata, {line, column}) + + case all(context, env, metadata) do + [] -> + nil + + list -> + %{ + docs: list, + range: %{ + begin: begin_pos, + end: end_pos + } + } + end + end + end + + defp all( + context, + %State.Env{ + module: module, + vars: vars + } = env, + metadata + ) do + binding_env = Binding.from_env(env, metadata) + + type = SurroundContext.to_binding(context.context, module) + + case type do + nil -> + nil + + {:keyword, keyword} -> + docs = ReservedWords.docs(keyword) + + %{ + name: Atom.to_string(keyword), + kind: :keyword, + docs: docs + } + + {:attribute, attribute} -> + docs = BuiltinAttributes.docs(attribute) || "" + + %{ + name: Atom.to_string(attribute), + kind: :attribute, + docs: docs + } + + {:variable, variable} -> + {line, column} = context.begin + + var_info = + vars + |> Enum.find(fn + %VarInfo{name: name, positions: positions} -> + name == variable and {line, column} in positions + end) + + if var_info != nil do + %{ + name: Atom.to_string(variable), + kind: :variable + } + else + mod_fun_docs( + type, + context, + binding_env, + env, + metadata + ) + end + + _ -> + mod_fun_docs( + type, + context, + binding_env, + env, + metadata + ) + end + |> List.wrap() + end + + defp mod_fun_docs( + {mod, fun}, + context, + binding_env, + env, + metadata + ) do + actual = + {Binding.expand(binding_env, mod), fun} + |> expand(env.aliases) + |> Introspection.actual_mod_fun( + env, + metadata.mods_funs_to_positions, + metadata.types, + context.begin, + false + ) + + case actual do + {mod, fun, true, kind} -> + {line, column} = context.end + call_arity = Metadata.get_call_arity(metadata, mod, fun, line, column) || :any + get_all_docs({mod, fun, call_arity}, metadata, env, kind) + + _ -> + nil + end + end + + def get_all_docs({mod, nil, _}, metadata, _env, :mod_fun) do + doc_info = + metadata.mods_funs_to_positions + |> Enum.find_value(fn + {{^mod, nil, nil}, fun_info = %ModFunInfo{}} -> + %{ + kind: :module, + module: mod, + metadata: fun_info.meta, + docs: fun_info.doc + } + + _ -> + false + end) + + if doc_info == nil do + get_module_docs(mod) + else + doc_info + end + end + + def get_all_docs({mod, fun, arity}, metadata, env, :mod_fun) do + doc_infos = + metadata.mods_funs_to_positions + |> Enum.filter(fn + {{^mod, ^fun, a}, fun_info} when not is_nil(a) and fun not in @builtin_functions -> + default_args = fun_info.params |> Enum.at(-1) |> Introspection.count_defaults() + + Introspection.matches_arity_with_defaults?(a, default_args, arity) + + _ -> + false + end) + |> Enum.sort_by(fn {{^mod, ^fun, a}, _fun_info} -> a end) + |> Enum.map(fn {{^mod, ^fun, a}, fun_info} -> + kind = ModFunInfo.get_category(fun_info) + + fun_args_text = + fun_info.params + |> List.last() + |> Enum.with_index() + |> Enum.map(&(&1 |> Introspection.param_to_var())) + + specs = + case metadata.specs[{mod, fun, a}] do + nil -> + [] + + %State.SpecInfo{specs: specs} -> + specs |> Enum.reverse() + end + + meta = fun_info.meta + + behaviour_implementation = + Metadata.get_module_behaviours(metadata, env, mod) + |> Enum.find_value(fn behaviour -> + if Introspection.is_callback(behaviour, fun, a, metadata) do + behaviour + end + end) + + case behaviour_implementation do + nil -> + %{ + kind: kind, + module: mod, + function: fun, + arity: a, + args: fun_args_text, + metadata: meta, + specs: specs, + docs: fun_info.doc + } + + behaviour -> + meta = Map.merge(meta, %{implementing: behaviour}) + + case metadata.specs[{behaviour, fun, a}] do + %State.SpecInfo{} = spec_info -> + specs = + spec_info.specs + |> Enum.reject(&String.starts_with?(&1, "@spec")) + |> Enum.reverse() + + {callback_doc, callback_meta} = + case metadata.mods_funs_to_positions[{behaviour, fun, a}] do + nil -> + {spec_info.doc, spec_info.meta} + + def_info -> + # in case of protocol implementation get doc and meta from def + {def_info.doc, def_info.meta} + end + + %{ + kind: kind, + module: mod, + function: fun, + arity: a, + args: fun_args_text, + metadata: callback_meta |> Map.merge(meta), + specs: specs, + docs: callback_doc + } + + nil -> + callback_docs_entry = + NormalizedCode.callback_documentation(behaviour) + |> Enum.find_value(fn + {{^fun, ^a}, doc} -> doc + _ -> false + end) + + case callback_docs_entry do + nil -> + # pass meta with implementing flag to trigger looking for specs in behaviour module + # assume there is a typespec for behaviour module + specs = [ + Introspection.get_spec_as_string( + mod, + fun, + a, + State.ModFunInfo.get_category(fun_info), + meta + ) + ] + + %{ + kind: kind, + module: mod, + function: fun, + arity: a, + args: fun_args_text, + metadata: meta, + specs: specs, + docs: "" + } + + {_, docs, callback_meta, mime_type} -> + app = ElixirSense.Core.Applications.get_application(behaviour) + docs = docs |> NormalizedCode.extract_docs(mime_type, behaviour, app) + # as of OTP 25 erlang callback doc entry does not have signature in meta + # pass meta with implementing flag to trigger looking for specs in behaviour module + # assume there is a typespec for behaviour module + specs = [ + Introspection.get_spec_as_string( + mod, + fun, + a, + State.ModFunInfo.get_category(fun_info), + meta + ) + ] + + %{ + kind: kind, + module: mod, + function: fun, + arity: a, + args: fun_args_text, + metadata: callback_meta |> Map.merge(meta), + specs: specs, + docs: docs || "" + } + end + end + end + end) + + if doc_infos == [] do + get_func_docs(mod, fun, arity) + else + doc_infos + end + end + + def get_all_docs({mod, fun, arity}, metadata, _env, :type) do + doc_infos = + metadata.types + |> Enum.filter(fn + {{^mod, ^fun, a}, _type_info} when not is_nil(a) -> + Introspection.matches_arity?(a, arity) + + _ -> + false + end) + |> Enum.sort_by(fn {{_mod, _fun, a}, _type_info} -> a end) + |> Enum.map(fn {{_mod, _fun, a}, type_info} -> + args = type_info.args |> List.last() + + spec = + case type_info.kind do + :opaque -> "@opaque #{fun}(#{args})" + _ -> List.last(type_info.specs) + end + + %{ + kind: :type, + module: mod, + type: fun, + arity: a, + args: args, + metadata: type_info.meta, + spec: spec, + docs: type_info.doc + } + end) + + if doc_infos == [] do + get_type_docs(mod, fun, arity) + else + doc_infos + end + end + + @spec get_module_docs(atom) :: + nil | module_doc() + def get_module_docs(mod) when is_atom(mod) do + case NormalizedCode.get_docs(mod, :moduledoc) do + {_line, text, metadata} -> + %{ + kind: :module, + module: mod, + metadata: metadata, + docs: text || "" + } + + _ -> + if Code.ensure_loaded?(mod) do + app = ElixirSense.Core.Applications.get_application(mod) + + %{ + kind: :module, + module: mod, + metadata: %{app: app}, + docs: "" + } + end + end + end + + # TODO spec + @spec get_func_docs(nil | module, atom, non_neg_integer | :any) :: list(function_doc()) + def get_func_docs(mod, fun, arity) + when mod != nil and fun in @builtin_functions do + for {f, a} <- BuiltinFunctions.all(), f == fun, Introspection.matches_arity?(a, arity) do + spec = BuiltinFunctions.get_specs({f, a}) + args = BuiltinFunctions.get_args({f, a}) + docs = BuiltinFunctions.get_docs({f, a}) + + metadata = %{builtin: true} + + %{ + kind: :function, + module: mod, + function: fun, + arity: a, + args: args, + metadata: metadata, + specs: spec, + docs: docs + } + end + end + + def get_func_docs(mod, fun, call_arity) do + case NormalizedCode.get_docs(mod, :docs) do + nil -> + # no docs, fallback to typespecs + get_func_docs_from_typespec(mod, fun, call_arity) + + docs -> + results = + for {{f, arity}, _, kind, args, text, metadata} <- docs, + f == fun, + Introspection.matches_arity_with_defaults?( + arity, + Map.get(metadata, :defaults, 0), + call_arity + ) do + fun_args_text = + Introspection.get_fun_args_from_doc_or_typespec(mod, f, arity, args, metadata) + + %{ + kind: kind, + module: mod, + function: fun, + arity: arity, + args: fun_args_text, + metadata: metadata, + specs: Introspection.get_specs_text(mod, fun, arity, kind, metadata), + docs: text || "" + } + end + + case results do + [] -> + get_func_docs_from_typespec(mod, fun, call_arity) + + other -> + other + end + end + end + + defp get_func_docs_from_typespec(mod, fun, call_arity) do + # TypeInfo.get_function_specs does fallback to behaviours + {behaviour, specs} = TypeInfo.get_function_specs(mod, fun, call_arity) + app = ElixirSense.Core.Applications.get_application(mod) + + meta = + if behaviour do + %{implementing: behaviour} + else + %{} + end + + results = + for {{_name, arity}, [params | _]} <- specs do + fun_args_text = TypeInfo.extract_params(params) + + %{ + kind: :function, + module: mod, + function: fun, + arity: arity, + args: fun_args_text, + metadata: meta |> Map.put(:app, app), + specs: Introspection.get_specs_text(mod, fun, arity, :function, meta), + docs: "" + } + end + + case results do + [] -> + # no docs and no typespecs + get_func_docs_from_module_info(mod, fun, call_arity) + + other -> + other + end + end + + defp get_func_docs_from_module_info(mod, fun, call_arity) do + # it is not worth doing fallback to behaviours here + # we'll not get much more useful info + + # provide dummy docs basing on module_info(:exports) + for {f, {arity, _kind}} <- Introspection.get_exports(mod), + f == fun, + Introspection.matches_arity?(arity, call_arity) do + fun_args_text = + if arity == 0, do: [], else: Enum.map(1..arity, fn _ -> "term" end) + + metadata = + if {f, arity} in BuiltinFunctions.erlang_builtin_functions(mod) do + %{builtin: true, app: :erts} + else + # TODO remove this fallback? + app = ElixirSense.Core.Applications.get_application(mod) + %{app: app} + end + + %{ + kind: :function, + module: mod, + function: fun, + arity: arity, + args: fun_args_text, + metadata: metadata, + specs: Introspection.get_specs_text(mod, fun, arity, :function, metadata), + docs: "" + } + end + end + + @spec get_type_docs(nil | module, atom, non_neg_integer | :any) :: list(type_doc()) + defp get_type_docs(nil, fun, arity) do + for info <- BuiltinTypes.get_builtin_type_info(fun), + Introspection.matches_arity?(length(info.params), arity) do + {spec, args} = + case info do + %{signature: signature, params: params} -> + {"@type #{signature}", Enum.map(params, &(&1 |> Atom.to_string()))} + + %{spec: spec_ast, params: params} -> + {TypeInfo.format_type_spec_ast(spec_ast, :type), + Enum.map(params, &(&1 |> Atom.to_string()))} + + _ -> + {"@type #{fun}()", []} + end + + %{ + kind: :type, + module: nil, + type: fun, + arity: length(info.params), + args: args, + metadata: %{builtin: true}, + spec: spec, + docs: info.doc + } + end + end + + defp get_type_docs(mod, fun, arity) do + docs = + (NormalizedCode.get_docs(mod, :type_docs) || []) + |> Enum.filter(fn {{name, n_args}, _, _, _, _} -> + name == fun and Introspection.matches_arity?(n_args, arity) + end) + |> Enum.sort_by(fn {{_, n_args}, _, _, _, _} -> n_args end) + + case docs do + [] -> + # TODO remove this fallback? + app = ElixirSense.Core.Applications.get_application(mod) + + for {kind, {name, _type, args}} = typedef <- Typespec.get_types(mod), + name == fun, + Introspection.matches_arity?(length(args), arity), + kind in [:type, :opaque] do + spec = TypeInfo.format_type_spec(typedef) + + type_args = Enum.map(args, &(&1 |> elem(2) |> Atom.to_string())) + + %{ + kind: :type, + module: mod, + type: fun, + arity: length(args), + args: type_args, + metadata: %{app: app}, + spec: spec, + docs: "" + } + end + + docs -> + for {{f, arity}, _, _, text, metadata} <- docs, f == fun do + spec = + mod + |> TypeInfo.get_type_spec(f, arity) + + {_kind, {_name, _def, args}} = spec + type_args = Enum.map(args, &(&1 |> elem(2) |> Atom.to_string())) + + %{ + kind: :type, + module: mod, + type: fun, + arity: arity, + args: type_args, + metadata: metadata, + spec: TypeInfo.format_type_spec(spec), + docs: text || "" + } + end + end + end + + def expand({{:atom, module}, func}, aliases) do + {Introspection.expand_alias(module, aliases), func} + end + + def expand({nil, func}, _aliases) do + {nil, func} + end + + def expand({:none, func}, _aliases) do + {nil, func} + end + + def expand({_, _func}, _aliases) do + {nil, nil} + end +end diff --git a/lib/elixir_sense/providers/implementation/locator.ex b/lib/elixir_sense/providers/implementation/locator.ex new file mode 100644 index 00000000..e6851fc9 --- /dev/null +++ b/lib/elixir_sense/providers/implementation/locator.ex @@ -0,0 +1,333 @@ +defmodule ElixirSense.Providers.Implementation.Locator do + @moduledoc """ + Provides a function to find out where symbols are implemented. + """ + + alias ElixirSense.Core.Behaviours + alias ElixirSense.Core.Binding + alias ElixirSense.Core.Metadata + alias ElixirSense.Core.Normalized + alias ElixirSense.Core.State + alias ElixirSense.Core.State.ModFunInfo + alias ElixirSense.Core.SurroundContext + alias ElixirSense.Providers.Location + alias ElixirSense.Core.Parser + alias ElixirSense.Core.Normalized.Code, as: NormalizedCode + + require ElixirSense.Core.Introspection, as: Introspection + + def implementations(code, line, column, options \\ []) do + case NormalizedCode.Fragment.surround_context(code, {line, column}) do + :none -> + [] + + context -> + metadata = + Keyword.get_lazy(options, :metadata, fn -> + Parser.parse_string(code, true, true, {line, column}) + end) + + env = Metadata.get_env(metadata, {line, column}) + + find( + context, + env, + metadata + ) + end + end + + @doc """ + Finds out where a callback, protocol or delegate was implemented. + """ + @spec find( + any(), + State.Env.t(), + Metadata.t() + ) :: [%Location{}] + def find( + context, + %State.Env{ + module: module + } = env, + metadata + ) do + binding_env = Binding.from_env(env, metadata) + + type = SurroundContext.to_binding(context.context, module) + + case type do + nil -> + [] + + {kind, _} when kind in [:attribute, :keyword] -> + [] + + {module_type, function} -> + module = + case Binding.expand(binding_env, module_type) do + {:atom, module} -> + Introspection.expand_alias(module, env.aliases) + + _ -> + env.module + end + + {line, column} = context.end + call_arity = Metadata.get_call_arity(metadata, module, function, line, column) || :any + + behaviour_implementations = + find_behaviour_implementations( + module, + function, + call_arity, + module, + env, + metadata, + binding_env + ) + + if behaviour_implementations == [] do + find_delegatee( + {module, function}, + call_arity, + env, + metadata, + binding_env + ) + |> List.wrap() + else + behaviour_implementations + end + end + end + + def find_behaviour_implementations( + maybe_found_module, + maybe_fun, + arity, + module, + env, + metadata, + binding_env + ) do + case maybe_found_module || module do + nil -> + [] + + found_module -> + found_module = expand(found_module, binding_env) + + cond do + maybe_fun == nil or Introspection.is_callback(found_module, maybe_fun, arity, metadata) -> + # protocol function call + get_locations(found_module, maybe_fun, arity, metadata) + + maybe_fun != nil -> + behaviours = Metadata.get_module_behaviours(metadata, env, module) + + # callback/protocol implementation def + for behaviour <- behaviours, + Introspection.is_callback(behaviour, maybe_fun, arity, metadata) do + get_locations(behaviour, maybe_fun, arity, metadata) + end + |> List.flatten() + + true -> + [] + end + end + |> Enum.reject(&is_nil/1) + end + + defp expand({kind, _attr} = type, binding_env) when kind in [:attribute, :variable] do + case Binding.expand(binding_env, type) do + {:atom, atom} -> atom + _ -> nil + end + end + + defp expand(other, _binding_env), do: other + + defp get_locations(behaviour, maybe_callback, arity, metadata) do + metadata_implementations = + for {_, env} <- metadata.lines_to_env, + behaviour in env.behaviours, + uniq: true, + do: env.module + + metadata_implementations_locations = + metadata_implementations + |> Enum.map(fn module -> + {{line, column}, info} = + metadata.mods_funs_to_positions + |> Enum.find_value(fn + {{^module, ^maybe_callback, _}, info} when is_nil(maybe_callback) -> + {List.last(info.positions), info} + + {{^module, ^maybe_callback, a}, info} when not is_nil(a) -> + defaults = info.params |> List.last() |> Introspection.count_defaults() + + if Introspection.matches_arity_with_defaults?(a, defaults, arity) do + {List.last(info.positions), info} + end + + _ -> + nil + end) + + kind = ModFunInfo.get_category(info) + + {module, %Location{type: kind, file: nil, line: line, column: column}} + end) + + introspection_implementations_locations = + Behaviours.get_all_behaviour_implementations(behaviour) + |> Enum.map(fn implementation -> + {implementation, Location.find_mod_fun_source(implementation, maybe_callback, arity)} + end) + + Keyword.merge(introspection_implementations_locations, metadata_implementations_locations) + |> Keyword.values() + end + + defp find_delegatee( + mf, + arity, + env, + metadata, + binding_env, + visited \\ [] + ) do + unless mf in visited do + do_find_delegatee( + mf, + arity, + env, + metadata, + binding_env, + [mf | visited] + ) + end + end + + defp do_find_delegatee( + {{kind, _} = type, function}, + arity, + env, + metadata, + binding_env, + visited + ) + when kind in [:attribute, :variable] do + case Binding.expand(binding_env, type) do + {:atom, module} -> + do_find_delegatee( + {module, function}, + arity, + env, + metadata, + binding_env, + visited + ) + + _ -> + nil + end + end + + defp do_find_delegatee( + {module, function}, + arity, + env, + metadata, + binding_env, + visited + ) do + case {module, function} + |> Introspection.actual_mod_fun( + env, + metadata.mods_funs_to_positions, + metadata.types, + # we don't expect macros here so no need to check position + {1, 1}, + true + ) do + {mod, fun, true, :mod_fun} when not is_nil(fun) -> + # on defdelegate - no need for arity fallback here + info = + Location.get_function_position_using_metadata( + mod, + fun, + arity, + metadata.mods_funs_to_positions + ) + + case info do + nil -> + find_delegatee_location(mod, fun, arity, visited) + + %ModFunInfo{type: :defdelegate, target: target} when not is_nil(target) -> + find_delegatee( + target, + arity, + env, + metadata, + binding_env, + visited + ) + + %ModFunInfo{positions: positions, type: :def} -> + # find_delegatee_location(mod, fun, arity, visited) + if length(visited) > 1 do + {line, column} = List.last(positions) + %Location{type: :function, file: nil, line: line, column: column} + end + + _ -> + nil + end + + _ -> + nil + end + end + + defp find_delegatee_location(mod, fun, arity, visited) do + defdelegate_from_docs = get_defdelegate_by_docs(mod, fun, arity) + + case defdelegate_from_docs do + nil -> + # ensure we are expanding a delegate + if length(visited) > 1 do + # on defdelegate - no need for arity fallback + Location.find_mod_fun_source(mod, fun, arity) + end + + {_, _, _, _, _, + %{ + delegate_to: {delegate_mod, delegate_fun, delegate_arity} + }} -> + # on call of delegated function - arity fallback already done + Location.find_mod_fun_source(delegate_mod, delegate_fun, delegate_arity) + end + end + + defp get_defdelegate_by_docs(mod, fun, arity) do + Normalized.Code.get_docs(mod, :docs) + |> List.wrap() + |> Enum.filter(fn + {{^fun, a}, _, :function, _, _, %{delegate_to: _} = meta} -> + default_args = Map.get(meta, :defaults, 0) + Introspection.matches_arity_with_defaults?(a, default_args, arity) + + _ -> + false + end) + |> Enum.min_by( + fn {{_, a}, _, _, _, _, _} -> a end, + &<=/2, + fn -> nil end + ) + end +end diff --git a/lib/elixir_sense/providers/location.ex b/lib/elixir_sense/providers/location.ex new file mode 100644 index 00000000..3ef768fa --- /dev/null +++ b/lib/elixir_sense/providers/location.ex @@ -0,0 +1,362 @@ +defmodule ElixirSense.Providers.Location do + @moduledoc """ + A location in a source file or buffer + """ + + alias ElixirSense.Core.Metadata + alias ElixirSense.Core.Parser + alias ElixirSense.Core.Source + alias ElixirSense.Core.State.ModFunInfo + alias ElixirSense.Core.Normalized.Code, as: CodeNormalized + require ElixirSense.Core.Introspection, as: Introspection + + @type t :: %__MODULE__{ + type: :module | :function | :variable | :typespec | :macro | :attribute, + file: String.t() | nil, + line: pos_integer, + column: pos_integer + } + defstruct [:type, :file, :line, :column] + + @spec find_mod_fun_source(module, atom, non_neg_integer | {:gte, non_neg_integer} | :any) :: + t() | nil + def find_mod_fun_source(mod, fun, arity) do + case find_mod_file(mod) do + file when is_binary(file) -> + find_fun_position({mod, file}, fun, arity) + + _ -> + nil + end + end + + @spec find_type_source(module, atom, non_neg_integer | {:gte, non_neg_integer} | :any) :: + t() | nil + def find_type_source(mod, type, arity) do + case find_mod_file(mod) do + file when is_binary(file) -> + find_type_position({mod, file}, type, arity) + + _ -> + nil + end + end + + defp find_mod_file(Elixir), do: nil + + defp find_mod_file(module) do + find_elixir_file(module) || find_erlang_file(module) + end + + defp find_elixir_file(module) do + file = + if Code.ensure_loaded?(module) do + case module.module_info(:compile)[:source] do + nil -> nil + source -> List.to_string(source) + end + end + + if file do + if File.exists?(file, [:raw]) do + file + else + # If Elixir was built in a sandboxed environment, + # `module.module_info(:compile)[:source]` would point to a non-existing + # location; in this case try to find a "core" Elixir source file under + # the configured Elixir source path. + with elixir_src when is_binary(elixir_src) <- + Application.get_env(:language_server, :elixir_src), + file = + String.replace( + file, + Regex.recompile!(~r<^(?:.+)(/lib/.+\.ex)$>U), + elixir_src <> "\\1" + ), + true <- File.exists?(file, [:raw]) do + file + else + _ -> nil + end + end + end + end + + defp find_erlang_file(module) do + with {_module, _binary, beam_filename} <- :code.get_object_code(module), + erl_file = + beam_filename + |> to_string + |> String.replace( + Regex.recompile!(~r/(.+)\/ebin\/([^\s]+)\.beam$/), + "\\1/src/\\2.erl" + ), + true <- File.exists?(erl_file, [:raw]) do + erl_file + else + _ -> nil + end + end + + defp find_fun_position({mod, file}, fun, arity) do + result = + if String.ends_with?(file, ".erl") do + # erlang function docs point to `-spec` instead of function + # module docs point to the begin of a file + # we get better results by regex + # the downside is we don't handle arity well + find_fun_position_in_erl_file(file, fun) + else + %Metadata{mods_funs_to_positions: mods_funs_to_positions} = + Parser.parse_file(file, false, false, nil) + + case get_function_position_using_metadata(mod, fun, arity, mods_funs_to_positions) do + %ModFunInfo{} = mi -> + # assume function head or first clause is last in metadata + {List.last(mi.positions), ModFunInfo.get_category(mi)} + + nil -> + # not found in metadata, fall back to docs + get_function_position_using_docs(mod, fun, arity) + end + end + + case result do + {{line, column}, category} -> + %__MODULE__{type: category, file: file, line: line, column: column} + + _ -> + nil + end + end + + defp find_fun_position_in_erl_file(file, nil) do + case find_line_by_regex(file, ~r/^-module/u) do + nil -> nil + position -> {position, :module} + end + end + + defp find_fun_position_in_erl_file(file, name) do + escaped = + name + |> Atom.to_string() + |> Regex.escape() + + case find_line_by_regex(file, ~r/^#{escaped}\b\(/u) do + nil -> nil + position -> {position, :function} + end + end + + defp find_type_position_in_erl_file(file, name) do + escaped = + name + |> Atom.to_string() + |> Regex.escape() + + find_line_by_regex(file, ~r/^-(typep?|opaque)\s#{escaped}\b/u) + end + + defp find_line_by_regex(file, regex) do + index = + file + |> File.read!() + |> Source.split_lines() + |> Enum.find_index(&String.match?(&1, regex)) + + case index do + nil -> nil + i -> {i + 1, 1} + end + end + + defp find_type_position(_, nil, _), do: nil + + defp find_type_position({mod, file}, name, arity) do + result = + if String.ends_with?(file, ".erl") do + find_type_position_in_erl_file(file, name) + else + file_metadata = Parser.parse_file(file, false, false, nil) + get_type_position(file_metadata, mod, name, arity) + end + + case result do + {line, column} -> + %__MODULE__{type: :typespec, file: file, line: line, column: column} + + _ -> + nil + end + end + + defp get_function_position_using_docs(module, nil, _) do + case CodeNormalized.fetch_docs(module) do + {:error, _} -> + nil + + {_, anno, _, _, _, _, _} -> + line = :erl_anno.line(anno) + + line = + if line == 0 do + 1 + else + line + end + + column = :erl_anno.column(anno) + + column = + if column == :undefined do + 1 + else + column + end + + {{line, column}, :module} + end + end + + defp get_function_position_using_docs(module, function, arity) do + case CodeNormalized.fetch_docs(module) do + {:error, _} -> + nil + + {_, _, _, _, _, _, docs} -> + docs + |> Enum.filter(fn + {{category, ^function, doc_arity}, _line, _, _, meta} + when category in [:function, :macro] -> + default_args = Map.get(meta, :defaults, 0) + Introspection.matches_arity_with_defaults?(doc_arity, default_args, arity) + + _ -> + false + end) + |> Enum.map(fn + {{category, _function, _arity}, line, _, _, _} when is_integer(line) -> + {{line, 1}, category} + + {{category, _function, _arity}, keyword, _, _, _} when is_list(keyword) -> + {{Keyword.get(keyword, :location, 1), 1}, category} + end) + |> Enum.min_by(fn {{line, 1}, _category} -> line end, &<=/2, fn -> nil end) + end + end + + def get_type_position(metadata, module, type, arity) do + case get_type_position_using_metadata(module, type, arity, metadata.types) do + nil -> + get_type_position_using_docs(module, type, arity) + + %ElixirSense.Core.State.TypeInfo{positions: positions} -> + List.last(positions) + end + end + + def get_type_position_using_docs(module, type_name, arity) do + case CodeNormalized.fetch_docs(module) do + {:error, _} -> + nil + + {_, _, _, _, _, _, docs} -> + docs + |> Enum.filter(fn + {{:type, ^type_name, doc_arity}, _line, _, _, _meta} -> + Introspection.matches_arity?(doc_arity, arity) + + _ -> + false + end) + |> Enum.map(fn + {{_category, _function, _arity}, line, _, _, _} when is_integer(line) -> + {line, 1} + + {{_category, _function, _arity}, keyword, _, _, _} when is_list(keyword) -> + {Keyword.get(keyword, :location, 1), 1} + end) + |> Enum.min_by(fn {line, 1} -> line end, &<=/2, fn -> nil end) + end + end + + def get_function_position_using_metadata( + mod, + fun, + call_arity, + mods_funs_to_positions, + predicate \\ fn _ -> true end + ) + + def get_function_position_using_metadata( + mod, + nil, + _call_arity, + mods_funs_to_positions, + predicate + ) do + mods_funs_to_positions + |> Enum.find_value(fn + {{^mod, nil, nil}, fun_info} -> + if predicate.(fun_info) do + fun_info + end + + _ -> + false + end) + end + + def get_function_position_using_metadata( + mod, + fun, + call_arity, + mods_funs_to_positions, + predicate + ) do + mods_funs_to_positions + |> Enum.filter(fn + {{^mod, ^fun, fn_arity}, fun_info} when not is_nil(fn_arity) -> + # assume function head is first in code and last in metadata + default_args = fun_info.params |> Enum.at(-1) |> Introspection.count_defaults() + + Introspection.matches_arity_with_defaults?(fn_arity, default_args, call_arity) and + predicate.(fun_info) + + _ -> + false + end) + |> min_by_line + end + + def get_type_position_using_metadata(mod, fun, call_arity, types, predicate \\ fn _ -> true end) do + types + |> Enum.filter(fn + {{^mod, ^fun, type_arity}, type_info} + when not is_nil(type_arity) and Introspection.matches_arity?(type_arity, call_arity) -> + predicate.(type_info) + + _ -> + false + end) + |> min_by_line + end + + defp min_by_line(list) do + result = + list + |> Enum.min_by( + fn {_, %{positions: positions}} -> + positions |> List.last() |> elem(0) + end, + &<=/2, + fn -> nil end + ) + + case result do + {_, info} -> info + nil -> nil + end + end +end diff --git a/lib/elixir_sense/providers/plugins/ecto.ex b/lib/elixir_sense/providers/plugins/ecto.ex new file mode 100644 index 00000000..52c4918e --- /dev/null +++ b/lib/elixir_sense/providers/plugins/ecto.ex @@ -0,0 +1,136 @@ +defmodule ElixirSense.Providers.Plugins.Ecto do + @moduledoc false + + alias ElixirSense.Providers.Plugins.ModuleStore + alias ElixirSense.Core.Source + alias ElixirSense.Providers.Plugins.Ecto.Query + alias ElixirSense.Providers.Plugins.Ecto.Schema + alias ElixirSense.Providers.Plugins.Ecto.Types + + @behaviour ElixirSense.Providers.Plugin + use ElixirSense.Providers.Completion.GenericReducer + + @schema_funcs [:field, :belongs_to, :has_one, :has_many, :many_to_many] + + @impl true + def setup(context) do + ModuleStore.ensure_compiled(context, Ecto.UUID) + end + + @impl true + def suggestions(hint, {Ecto.Migration, :add, 1, _info}, _chain, opts) do + builtin_types = Types.find_builtin_types(hint, opts.cursor_context) + builtin_types = Enum.reject(builtin_types, &String.starts_with?(&1.label, "{")) + + {:override, builtin_types} + end + + def suggestions(hint, {Ecto.Schema, :field, 1, _info}, _chain, opts) do + builtin_types = Types.find_builtin_types(hint, opts.cursor_context) + custom_types = Types.find_custom_types(hint, opts.module_store) + + {:override, builtin_types ++ custom_types} + end + + def suggestions(hint, {Ecto.Schema, func, 1, _info}, _chain, opts) + when func in @schema_funcs do + {:override, Schema.find_schemas(hint, opts.module_store)} + end + + def suggestions(hint, {Ecto.Schema, func, 2, %{option: option}}, _, _) + when func in @schema_funcs and option != nil do + {:override, Schema.find_option_values(hint, option, func)} + end + + def suggestions(_hint, {Ecto.Schema, func, 2, %{cursor_at_option: false}}, _, _) + when func in @schema_funcs do + :ignore + end + + def suggestions(hint, {Ecto.Schema, func, 2, _info}, _, _) + when func in @schema_funcs do + {:override, Schema.find_options(hint, func)} + end + + def suggestions(hint, {Ecto.Query, :from, 0, _info}, _, opts) do + text_before = opts.cursor_context.text_before + + if after_in?(hint, text_before) do + {:add, Schema.find_schemas(hint, opts.module_store)} + else + :ignore + end + end + + def suggestions( + hint, + _, + [{nil, :assoc, 1, assoc_info} | rest], + opts + ) do + text_before = opts.cursor_context.text_before + env = opts.env + meta = opts.buffer_metadata + + with %{pos: {{line, col}, _}} <- assoc_info, + from_info when not is_nil(from_info) <- + Enum.find_value(rest, fn + {Ecto.Query, :from, 1, from_info} -> from_info + _ -> nil + end), + assoc_code <- Source.text_after(text_before, line, col), + [_, var] <- + Regex.run(~r/^assoc\(\s*([_\p{Ll}\p{Lo}][\p{L}\p{N}_]*[?!]?)\s*,/u, assoc_code), + %{^var => %{type: type}} <- Query.extract_bindings(text_before, from_info, env, meta), + true <- function_exported?(type, :__schema__, 1) do + {:override, Query.find_assoc_suggestions(type, hint)} + else + _ -> + :ignore + end + end + + def suggestions(hint, _func_call, chain, opts) do + case Enum.find(chain, &match?({Ecto.Query, :from, 1, _}, &1)) do + {_, _, _, %{cursor_at_option: false} = info} -> + text_before = opts.cursor_context.text_before + env = opts.env + buffer_metadata = opts.buffer_metadata + + schemas = + if after_in?(hint, text_before), + do: Schema.find_schemas(hint, opts.module_store), + else: [] + + bindings = Query.extract_bindings(text_before, info, env, buffer_metadata) + {:add, schemas ++ Query.bindings_suggestions(hint, bindings)} + + {_, _, _, _} -> + {:override, Query.find_options(hint)} + + _ -> + :ignore + end + end + + # Adds customized snippet for `Ecto.Schema.schema/2` + @impl true + def decorate(%{origin: "Ecto.Schema", name: "schema", arity: 2} = item) do + snippet = """ + schema "$1" do + $0 + end + """ + + Map.put(item, :snippet, snippet) + end + + # Fallback + def decorate(item) do + item + end + + defp after_in?(hint, text_before) do + Regex.match?(~r/\s+in\s+#{Regex.escape(hint)}$/u, text_before) + end +end diff --git a/lib/elixir_sense/providers/plugins/ecto/query.ex b/lib/elixir_sense/providers/plugins/ecto/query.ex new file mode 100644 index 00000000..6677c4da --- /dev/null +++ b/lib/elixir_sense/providers/plugins/ecto/query.ex @@ -0,0 +1,270 @@ +defmodule ElixirSense.Providers.Plugins.Ecto.Query do + @moduledoc false + + alias ElixirSense.Core.Introspection + alias ElixirSense.Core.Source + alias ElixirSense.Providers.Plugins.Util + alias ElixirSense.Providers.Utils.Matcher + + # We'll keep these values hard-coded until Ecto provides the same information + # using docs' metadata. + + @joins [ + :join, + :inner_join, + :cross_join, + :left_join, + :right_join, + :full_join, + :inner_lateral_join, + :left_lateral_join + ] + + @from_join_opts [ + as: "A named binding for the from/join.", + prefix: "The prefix to be used for the from/join when issuing a database query.", + hints: "A string or a list of strings to be used as database hints." + ] + + @join_opts [on: "A query expression or keyword list to filter the join."] + + @var_r "[_\p{Ll}\p{Lo}][\p{L}\p{N}_]*[?!]?" + # elixir alias must be ASCII, no need to support unicode here + @mod_r "[A-Z][a-zA-Z0-9_\.]*" + @binding_r "(#{@var_r}) in (#{@mod_r}|assoc\\(\\s*#{@var_r},\\s*\\:#{@var_r}\\s*\\))" + + def find_assoc_suggestions(type, hint) do + for assoc <- type.__schema__(:associations), + assoc_str = inspect(assoc), + Matcher.match?(assoc_str, hint) do + assoc_mod = type.__schema__(:association, assoc).related + {doc, _} = Introspection.get_module_docs_summary(assoc_mod) + + %{ + type: :generic, + kind: :field, + label: assoc_str, + detail: "(Ecto association) #{inspect(assoc_mod)}", + documentation: doc + } + end + end + + def find_options(hint) do + clauses_suggestions(hint) ++ joins_suggestions(hint) ++ join_opts_suggestions(hint) + end + + defp clauses_suggestions(hint) do + funs = ElixirSense.Providers.Completion.CompletionEngine.get_module_funs(Ecto.Query, false) + + for {name, arity, arity, :macro, {doc, _}, _, ["query" | _]} <- funs, + clause = to_string(name), + Matcher.match?(clause, hint) do + clause_to_suggestion(clause, doc, "from clause") + end + end + + defp joins_suggestions(hint) do + for name <- @joins -- [:join], + clause = to_string(name), + Matcher.match?(clause, hint) do + join_kind = String.replace(clause, "_", " ") + doc = "A #{join_kind} query expression." + clause_to_suggestion(clause, doc, "from clause") + end + end + + defp join_opts_suggestions(hint) do + for {name, doc} <- @join_opts ++ @from_join_opts, + clause = to_string(name), + Matcher.match?(clause, hint) do + type = if Keyword.has_key?(@join_opts, name), do: "join", else: "from/join" + clause_to_suggestion(clause, doc, "#{type} option") + end + end + + defp find_fields(type, hint) do + with {:module, _} <- Code.ensure_compiled(type), + true <- function_exported?(type, :__schema__, 1) do + for field <- Enum.sort(type.__schema__(:fields)), + name = to_string(field), + Matcher.match?(name, hint) do + %{name: field, type: type.__schema__(:type, field)} + end + else + _ -> + [] + end + end + + defp find_field_relations(field, type) do + associations = type.__schema__(:associations) + + for assoc_name <- associations, + assoc = type.__schema__(:association, assoc_name), + assoc.owner == type, + assoc.owner_key == field.name do + assoc + end + end + + def bindings_suggestions(hint, bindings) do + case String.split(hint, ".") do + [var, field_hint] -> + type = bindings[var][:type] + + type + |> find_fields(field_hint) + |> Enum.map(fn f -> field_to_suggestion(f, type) end) + + _ -> + for {name, %{type: type}} <- bindings, + Matcher.match?(name, hint) do + binding_to_suggestion(name, type) + end + end + end + + defp clause_to_suggestion(option, doc, detail) do + doc_str = + doc + |> doc_sections() + |> Enum.filter(fn {k, _v} -> k in [:summary, "Keywords examples", "Keywords example"] end) + |> Enum.map_join("\n\n", fn + {:summary, text} -> + text + + {_, text} -> + [first | _] = String.split(text, "\n\n") + if first == "", do: "", else: "### Example\n\n#{first}" + end) + + %{ + type: :generic, + kind: :property, + label: option, + insert_text: "#{option}: ", + detail: "(#{detail}) Ecto.Query", + documentation: doc_str + } + end + + defp binding_to_suggestion(binding, type) do + {doc, _} = Introspection.get_module_docs_summary(type) + + %{ + type: :generic, + kind: :variable, + label: binding, + detail: "(query binding) #{inspect(type)}", + documentation: doc + } + end + + defp field_to_suggestion(field, origin) do + type_str = inspect(field.type) + associations = find_field_relations(field, origin) + + relations = + Enum.map_join(associations, ", ", fn + %{related: related, related_key: related_key} -> + "`#{inspect(related)} (#{inspect(related_key)})`" + + %{related: related} -> + # Ecto.Association.ManyToMany does not define :related_key + "`#{inspect(related)}`" + end) + + related_info = if relations == "", do: "", else: "* **Related:** #{relations}" + + doc = """ + The `#{inspect(field.name)}` field of `#{inspect(origin)}`. + + * **Type:** `#{type_str}` + #{related_info} + """ + + %{ + type: :generic, + kind: :field, + label: to_string(field.name), + detail: "Ecto field", + documentation: doc + } + end + + defp infer_type({:__aliases__, _, [{:__MODULE__, _, _} | list]}, _vars, env, buffer_metadata) do + mod = Module.concat(env.module, Module.concat(list)) + {actual_mod, _, _, _} = Util.actual_mod_fun({mod, nil}, false, env, buffer_metadata) + actual_mod + end + + defp infer_type({:__aliases__, _, list}, _vars, env, buffer_metadata) do + mod = Module.concat(list) + + {actual_mod, _, _, _} = + Util.actual_mod_fun({mod, nil}, hd(list) == Elixir, env, buffer_metadata) + + actual_mod + end + + defp infer_type({:assoc, _, [{var, _, _}, assoc]}, vars, _env, _buffer_metadata) do + var_type = vars[to_string(var)][:type] + + if var_type && function_exported?(var_type, :__schema__, 2) do + var_type.__schema__(:association, assoc).related + end + end + + defp infer_type(_, _vars, _env, _buffer_metadata) do + nil + end + + def extract_bindings(prefix, %{pos: {{line, col}, _}} = func_info, env, buffer_metadata) do + func_code = Source.text_after(prefix, line, col) + + from_matches = Regex.scan(~r/^.+\(?\s*(#{@binding_r})/u, func_code) + + # TODO this code is broken + # depends on join positions that we are unable to get from AST + # line and col was previously assigned to each option in Source.which_func + join_matches = + for join when join in @joins <- func_info.options_so_far, + code = Source.text_after(prefix, line, col), + match <- Regex.scan(~r/^#{Regex.escape(join)}\:\s*(#{@binding_r})/u, code) do + match + end + + matches = from_matches ++ join_matches + + Enum.reduce(matches, %{}, fn [_, _, var, expr], bindings -> + case Code.string_to_quoted(expr) do + {:ok, expr_ast} -> + type = infer_type(expr_ast, bindings, env, buffer_metadata) + Map.put(bindings, var, %{type: type}) + + _ -> + bindings + end + end) + end + + def extract_bindings(_prefix, _func_info, _env, _buffer_metadata) do + %{} + end + + defp doc_sections(doc) do + [summary_and_detail | rest] = String.split(doc, "##") + summary_and_detail_parts = Source.split_lines(summary_and_detail, parts: 2) + summary = summary_and_detail_parts |> Enum.at(0, "") |> String.trim() + detail = summary_and_detail_parts |> Enum.at(1, "") |> String.trim() + + sections = + Enum.map(rest, fn text -> + [title, body] = Source.split_lines(text, parts: 2) + {String.trim(title), String.trim(body, "\n")} + end) + + [{:summary, summary}, {:detail, detail}] ++ sections + end +end diff --git a/lib/elixir_sense/providers/plugins/ecto/schema.ex b/lib/elixir_sense/providers/plugins/ecto/schema.ex new file mode 100644 index 00000000..d672116d --- /dev/null +++ b/lib/elixir_sense/providers/plugins/ecto/schema.ex @@ -0,0 +1,457 @@ +defmodule ElixirSense.Providers.Plugins.Ecto.Schema do + @moduledoc false + + alias ElixirSense.Core.Introspection + alias ElixirSense.Providers.Plugins.Option + alias ElixirSense.Providers.Plugins.Util + alias ElixirSense.Providers.Utils.Matcher + + # We'll keep these values hard-coded until Ecto provides the same information + # using docs' metadata. + + @on_replace_values [ + raise: """ + (default) - do not allow removing association or embedded + data via parent changesets + """, + mark_as_invalid: """ + If attempting to remove the association or + embedded data via parent changeset - an error will be added to the parent + changeset, and it will be marked as invalid + """, + nilify: """ + Sets owner reference column to `nil` (available only for + associations). Use this on a `belongs_to` column to allow the association + to be cleared out so that it can be set to a new value. Will set `action` + on associated changesets to `:replace` + """, + update: """ + Updates the association, available only for `has_one` and `belongs_to`. + This option will update all the fields given to the changeset including the id + for the association + """, + delete: """ + Removes the association or related data from the database. + This option has to be used carefully (see below). Will set `action` on associated + changesets to `:replace` + """ + ] + + @on_delete_values [ + nothing: "(default) - do nothing to the associated records when the parent record is deleted", + nilify_all: "Sets the key in the associated table to `nil`", + delete_all: "Deletes the associated records when the parent record is deleted" + ] + + @options %{ + field: [ + %{ + name: :default, + doc: """ + Sets the default value on the schema and the struct. + The default value is calculated at compilation time, so don't use + expressions like `DateTime.utc_now` or `Ecto.UUID.generate` as + they would then be the same for all records. + """ + }, + %{ + name: :source, + doc: """ + Defines the name that is to be used in database for this field. + This is useful when attaching to an existing database. The value should be + an atom. + """ + }, + %{ + name: :autogenerate, + doc: """ + a `{module, function, args}` tuple for a function + to call to generate the field value before insertion if value is not set. + A shorthand value of `true` is equivalent to `{type, :autogenerate, []}`. + """ + }, + %{ + name: :read_after_writes, + doc: """ + When true, the field is always read back + from the database after insert and updates. + For relational databases, this means the RETURNING option of those + statements is used. For this reason, MySQL does not support this + option and will raise an error if a schema is inserted/updated with + read after writes fields. + """ + }, + %{ + name: :virtual, + doc: """ + When true, the field is not persisted to the database. + Notice virtual fields do not support `:autogenerate` nor + `:read_after_writes`. + """ + }, + %{ + name: :primary_key, + doc: """ + When true, the field is used as part of the + composite primary key. + """ + }, + %{ + name: :load_in_query, + doc: """ + When false, the field will not be loaded when + selecting the whole struct in a query, such as `from p in Post, select: p`. + Defaults to `true`. + """ + } + ], + belongs_to: [ + %{ + name: :foreign_key, + doc: """ + Sets the foreign key field name, defaults to the name + of the association suffixed by `_id`. For example, `belongs_to :company` + will define foreign key of `:company_id`. The associated `has_one` or `has_many` + field in the other schema should also have its `:foreign_key` option set + with the same value. + """ + }, + %{ + name: :references, + doc: """ + Sets the key on the other schema to be used for the + association, defaults to: `:id` + """ + }, + %{ + name: :define_field, + doc: """ + When false, does not automatically define a `:foreign_key` + field, implying the user is defining the field manually elsewhere + """ + }, + %{ + name: :type, + doc: """ + Sets the type of automatically defined `:foreign_key`. + Defaults to: `:integer` and can be set per schema via `@foreign_key_type` + """ + }, + %{ + name: :on_replace, + doc: """ + The action taken on associations when the record is + replaced when casting or manipulating parent changeset. May be + `:raise` (default), `:mark_as_invalid`, `:nilify`, `:update`, or `:delete`. + See `Ecto.Changeset`'s section on related data for more info. + """, + values: @on_replace_values + }, + %{ + name: :defaults, + doc: """ + Default values to use when building the association. + It may be a keyword list of options that override the association schema + or a `{module, function, args}` that receive the struct and the owner as + arguments. For example, if you set `Post.has_many :comments, defaults: [public: true]`, + then when using `Ecto.build_assoc(post, :comments)` that comment will have + `comment.public == true`. Alternatively, you can set it to + `Post.has_many :comments, defaults: {__MODULE__, :update_comment, []}` + and `Post.update_comment(comment, post)` will be invoked. + """ + }, + %{ + name: :primary_key, + doc: """ + If the underlying belongs_to field is a primary key + """ + }, + %{ + name: :source, + doc: """ + Defines the name that is to be used in database for this field + """ + }, + %{ + name: :where, + doc: """ + A filter for the association. See "Filtering associations" + in `has_many/3`. + """ + } + ], + has_one: [ + %{ + name: :foreign_key, + doc: """ + Sets the foreign key, this should map to a field on the + other schema, defaults to the underscored name of the current module + suffixed by `_id` + """ + }, + %{ + name: :references, + doc: """ + Sets the key on the current schema to be used for the + association, defaults to the primary key on the schema + """ + }, + %{ + name: :through, + doc: """ + If this association must be defined in terms of existing + associations. Read the section in `has_many/3` for more information + """ + }, + %{ + name: :on_delete, + doc: """ + The action taken on associations when parent record + is deleted. May be `:nothing` (default), `:nilify_all` and `:delete_all`. + Using this option is DISCOURAGED for most relational databases. Instead, + in your migration, set `references(:parent_id, on_delete: :delete_all)`. + Opposite to the migration option, this option cannot guarantee integrity + and it is only triggered for `c:Ecto.Repo.delete/2` (and not on + `c:Ecto.Repo.delete_all/2`) and it never cascades. If posts has many comments, + which has many tags, and you delete a post, only comments will be deleted. + If your database does not support references, cascading can be manually + implemented by using `Ecto.Multi` or `Ecto.Changeset.prepare_changes/2` + """, + values: @on_delete_values + }, + %{ + name: :on_replace, + doc: """ + The action taken on associations when the record is + replaced when casting or manipulating parent changeset. May be + `:raise` (default), `:mark_as_invalid`, `:nilify`, `:update`, or + `:delete`. See `Ecto.Changeset`'s section on related data for more info. + """, + values: @on_replace_values + }, + %{ + name: :defaults, + doc: """ + Default values to use when building the association. + It may be a keyword list of options that override the association schema + or a `{module, function, args}` that receive the struct and the owner as + arguments. For example, if you set `Post.has_many :comments, defaults: [public: true]`, + then when using `Ecto.build_assoc(post, :comments)` that comment will have + `comment.public == true`. Alternatively, you can set it to + `Post.has_many :comments, defaults: {__MODULE__, :update_comment, []}` + and `Post.update_comment(comment, post)` will be invoked. + """ + }, + %{ + name: :where, + doc: """ + A filter for the association. See "Filtering associations" + in `has_many/3`. It does not apply to `:through` associations. + """ + } + ], + has_many: [ + %{ + name: :foreign_key, + doc: """ + Sets the foreign key, this should map to a field on the + other schema, defaults to the underscored name of the current module + suffixed by `_id`. + """ + }, + %{ + name: :references, + doc: """ + Sets the key on the current schema to be used for the + association, defaults to the primary key on the schema. + """ + }, + %{ + name: :through, + doc: """ + If this association must be defined in terms of existing + associations. Read the section in `has_many/3` for more information. + """ + }, + %{ + name: :on_delete, + doc: """ + The action taken on associations when parent record + is deleted. May be `:nothing` (default), `:nilify_all` and `:delete_all`. + Using this option is DISCOURAGED for most relational databases. Instead, + in your migration, set `references(:parent_id, on_delete: :delete_all)`. + Opposite to the migration option, this option cannot guarantee integrity + and it is only triggered for `c:Ecto.Repo.delete/2` (and not on + `c:Ecto.Repo.delete_all/2`) and it never cascades. If posts has many comments, + which has many tags, and you delete a post, only comments will be deleted. + If your database does not support references, cascading can be manually + implemented by using `Ecto.Multi` or `Ecto.Changeset.prepare_changes/2`. + """, + values: @on_delete_values + }, + %{ + name: :on_replace, + doc: """ + The action taken on associations when the record is + replaced when casting or manipulating parent changeset. May be + `:raise` (default), `:mark_as_invalid`, `:nilify`, `:update`, or + `:delete`. See `Ecto.Changeset`'s section on related data for more info. + """, + values: @on_replace_values + }, + %{ + name: :defaults, + doc: """ + Default values to use when building the association. + It may be a keyword list of options that override the association schema + or a `{module, function, args}` that receive the struct and the owner as + arguments. For example, if you set `Post.has_many :comments, defaults: [public: true]`, + then when using `Ecto.build_assoc(post, :comments)` that comment will have + `comment.public == true`. Alternatively, you can set it to + `Post.has_many :comments, defaults: {__MODULE__, :update_comment, []}` + and `Post.update_comment(comment, post)` will be invoked. + """ + }, + %{ + name: :where, + doc: """ + A filter for the association. See "Filtering associations" + in `has_many/3`. It does not apply to `:through` associations. + """ + } + ], + many_to_many: [ + %{ + name: :join_through, + doc: """ + Specifies the source of the associated data. + It may be a string, like "posts_tags", representing the + underlying storage table or an atom, like `MyApp.PostTag`, + representing a schema. This option is required. + """ + }, + %{ + name: :join_keys, + doc: """ + Specifies how the schemas are associated. It + expects a keyword list with two entries, the first being how + the join table should reach the current schema and the second + how the join table should reach the associated schema. In the + example above, it defaults to: `[post_id: :id, tag_id: :id]`. + The keys are inflected from the schema names. + """ + }, + %{ + name: :on_delete, + doc: """ + The action taken on associations when the parent record + is deleted. May be `:nothing` (default) or `:delete_all`. + Using this option is DISCOURAGED for most relational databases. Instead, + in your migration, set `references(:parent_id, on_delete: :delete_all)`. + Opposite to the migration option, this option cannot guarantee integrity + and it is only triggered for `c:Ecto.Repo.delete/2` (and not on + `c:Ecto.Repo.delete_all/2`). This option can only remove data from the + join source, never the associated records, and it never cascades. + """, + values: Keyword.take(@on_delete_values, [:nothing, :delete_all]) + }, + %{ + name: :on_replace, + doc: """ + The action taken on associations when the record is + replaced when casting or manipulating parent changeset. May be + `:raise` (default), `:mark_as_invalid`, or `:delete`. + `:delete` will only remove data from the join source, never the + associated records. See `Ecto.Changeset`'s section on related data + for more info. + """, + values: Keyword.take(@on_replace_values, [:raise, :mark_as_invalid, :delete]) + }, + %{ + name: :defaults, + doc: """ + Default values to use when building the association. + It may be a keyword list of options that override the association schema + or a `{module, function, args}` that receive the struct and the owner as + arguments. For example, if you set `Post.has_many :comments, defaults: [public: true]`, + then when using `Ecto.build_assoc(post, :comments)` that comment will have + `comment.public == true`. Alternatively, you can set it to + `Post.has_many :comments, defaults: {__MODULE__, :update_comment, []}` + and `Post.update_comment(comment, post)` will be invoked. + """ + }, + %{ + name: :join_defaults, + doc: """ + The same as `:defaults` but it applies to the join schema + instead. This option will raise if it is given and the `:join_through` value + is not a schema. + """ + }, + %{ + name: :unique, + doc: """ + When true, checks if the associated entries are unique + whenever the association is cast or changed via the parent record. + For instance, it would verify that a given tag cannot be attached to + the same post more than once. This exists mostly as a quick check + for user feedback, as it does not guarantee uniqueness at the database + level. Therefore, you should also set a unique index in the database + join table, such as: `create unique_index(:posts_tags, [:post_id, :tag_id])` + """ + }, + %{ + name: :where, + doc: """ + A filter for the association. See "Filtering associations" + in `has_many/3` + """ + }, + %{ + name: :join_where, + doc: """ + A filter for the join table. See "Filtering associations" + in `has_many/3` + """ + } + ] + } + + def find_options(hint, fun) do + @options[fun] |> Option.find(hint, fun) + end + + def find_option_values(hint, option, fun) do + for {value, doc} <- Enum.find(@options[fun], &(&1.name == option))[:values] || [], + value_str = inspect(value), + Matcher.match?(value_str, hint) do + %{ + type: :generic, + kind: :enum_member, + label: value_str, + insert_text: Util.trim_leading_for_insertion(hint, value_str), + detail: "#{inspect(option)} value", + documentation: doc + } + end + end + + def find_schemas(hint, module_store) do + for module <- module_store.list, + function_exported?(module, :__schema__, 1), + mod_str = inspect(module), + Util.match_module?(mod_str, hint) do + {doc, _} = Introspection.get_module_docs_summary(module) + + %{ + type: :generic, + kind: :class, + label: mod_str, + insert_text: Util.trim_leading_for_insertion(hint, mod_str), + detail: "Ecto schema", + documentation: doc + } + end + |> Enum.sort_by(& &1.label) + end +end diff --git a/lib/elixir_sense/providers/plugins/ecto/types.ex b/lib/elixir_sense/providers/plugins/ecto/types.ex new file mode 100644 index 00000000..2581493f --- /dev/null +++ b/lib/elixir_sense/providers/plugins/ecto/types.ex @@ -0,0 +1,108 @@ +defmodule ElixirSense.Providers.Plugins.Ecto.Types do + @moduledoc false + + alias ElixirSense.Core.Introspection + alias ElixirSense.Providers.Plugins.Util + alias ElixirSense.Providers.Utils.Matcher + + # We'll keep these values hard-coded until Ecto provides the same information + # using docs' metadata. + + @ecto_types [ + {":string", "string UTF-8 encoded", "\"hello\"", nil}, + {":boolean", "boolean", "true, false", nil}, + {":integer", "integer", "1, 2, 3", nil}, + {":float", "float", "1.0, 2.0, 3.0", nil}, + {":decimal", "Decimal", nil, nil}, + {":id", "integer", "1, 2, 3", nil}, + {":date", "Date", nil, nil}, + {":time", "Time", nil, nil}, + {":time_usec", "Time", nil, nil}, + {":naive_datetime", "NaiveDateTime", nil, nil}, + {":naive_datetime_usec", "NaiveDateTime", nil, nil}, + {":utc_datetime", "DateTime", nil, nil}, + {":utc_datetime_usec", "DateTime", nil, nil}, + {"{:array, inner_type}", "list", "[value, value, ...]", "{:array, ${1:inner_type}}"}, + {":map", "map", nil, nil}, + {"{:map, inner_type}", "map", nil, "{:map, ${1:inner_type}}"}, + {":binary_id", "binary", "<>", nil}, + {":binary", "binary", "<>", nil} + ] + + def find_builtin_types(hint, cursor_context) do + text_before = cursor_context.text_before + text_after = cursor_context.text_after + + actual_hint = + if String.ends_with?(text_before, "{" <> hint) do + "{" <> hint + else + hint + end + + for {name, _, _, _} = type <- @ecto_types, + Matcher.match?(name, actual_hint) do + buitin_type_to_suggestion(type, actual_hint, text_after) + end + end + + def find_custom_types(hint, module_store) do + for module <- Map.get(module_store.by_behaviour, Ecto.Type, []), + type_str = inspect(module), + Util.match_module?(type_str, hint) do + custom_type_to_suggestion(module, hint) + end + end + + defp buitin_type_to_suggestion({type, elixir_type, literal_syntax, snippet}, hint, text_after) do + [_, hint_prefix] = Regex.run(~r/(.*?)[\w0-9\._!\?\->]*$/u, hint) + + insert_text = String.replace_prefix(type, hint_prefix, "") + snippet = snippet && String.replace_prefix(snippet, hint_prefix, "") + + {insert_text, snippet} = + if String.starts_with?(text_after, "}") do + snippet = snippet && String.replace_suffix(snippet, "}", "") + insert_text = String.replace_suffix(insert_text, "}", "") + {insert_text, snippet} + else + {insert_text, snippet} + end + + literal_syntax_info = + if literal_syntax, do: "* **Literal syntax:** `#{literal_syntax}`", else: "" + + doc = """ + Built-in Ecto type. + + * **Elixir type:** `#{elixir_type}` + #{literal_syntax_info}\ + """ + + %{ + type: :generic, + kind: :type_parameter, + label: type, + insert_text: insert_text, + snippet: snippet, + detail: "Ecto type", + documentation: doc, + priority: 0 + } + end + + defp custom_type_to_suggestion(type, hint) do + type_str = inspect(type) + {doc, _} = Introspection.get_module_docs_summary(type) + + %{ + type: :generic, + kind: :type_parameter, + label: type_str, + insert_text: Util.trim_leading_for_insertion(hint, type_str), + detail: "Ecto custom type", + documentation: doc, + priority: 1 + } + end +end diff --git a/lib/elixir_sense/providers/plugins/module_store.ex b/lib/elixir_sense/providers/plugins/module_store.ex new file mode 100644 index 00000000..b404076f --- /dev/null +++ b/lib/elixir_sense/providers/plugins/module_store.ex @@ -0,0 +1,88 @@ +defmodule ElixirSense.Providers.Plugins.ModuleStore do + @moduledoc """ + Caches the module list and a list of modules keyed by the behaviour they implement. + """ + defstruct by_behaviour: %{}, list: [], plugins: [] + + @type t :: %__MODULE__{ + by_behaviour: %{optional(atom) => module}, + list: list(module), + plugins: list(module) + } + + alias ElixirSense.Core.Applications + + def ensure_compiled(context, module_or_modules) do + modules = List.wrap(module_or_modules) + Enum.each(modules, &Code.ensure_compiled/1) + + Map.update!(context, :module_store, &build(modules, &1)) + end + + def build(list \\ all_loaded(), module_store \\ %__MODULE__{}) do + Enum.reduce(list, module_store, fn module, module_store -> + try do + module_store = %{module_store | list: [module | module_store.list]} + + module_store = + if is_plugin?(module) do + %{module_store | plugins: [module | module_store.plugins]} + else + module_store + end + + module.module_info(:attributes) + |> Enum.flat_map(fn + {:behaviour, behaviours} when is_list(behaviours) -> + behaviours + + _ -> + [] + end) + |> Enum.reduce(module_store, &add_behaviour(module, &1, &2)) + rescue + _ -> + module_store + end + end) + end + + defp is_plugin?(module) do + module.module_info(:attributes) + |> Enum.any?(fn + {:behaviour, behaviours} when is_list(behaviours) -> + ElixirSense.Plugin in behaviours or ElixirLS.LanguageServer.Plugin in behaviours + + {:is_elixir_sense_plugin, true} -> + true + + {:is_elixir_ls_plugin, true} -> + true + + _ -> + false + end) + end + + defp all_loaded do + Applications.get_modules_from_applications() + |> Enum.filter(fn module -> + try do + _ = Code.ensure_compiled(module) + function_exported?(module, :module_info, 0) + rescue + _ -> + false + end + end) + end + + defp add_behaviour(adopter, behaviour, module_store) do + new_by_behaviour = + module_store.by_behaviour + |> Map.put_new_lazy(behaviour, fn -> MapSet.new() end) + |> Map.update!(behaviour, &MapSet.put(&1, adopter)) + + %{module_store | by_behaviour: new_by_behaviour} + end +end diff --git a/lib/elixir_sense/providers/plugins/option.ex b/lib/elixir_sense/providers/plugins/option.ex new file mode 100644 index 00000000..2d978384 --- /dev/null +++ b/lib/elixir_sense/providers/plugins/option.ex @@ -0,0 +1,38 @@ +defmodule ElixirSense.Providers.Plugins.Option do + @moduledoc false + + alias ElixirSense.Providers.Plugins.Util + alias ElixirSense.Providers.Utils.Matcher + + def find(options, hint, fun) do + for option <- options, match_hint?(option, hint) do + to_suggestion(option, fun) + end + |> Enum.sort_by(& &1.label) + end + + def to_suggestion(option, fun) do + command = + if option[:values] not in [nil, []] do + Util.command(:trigger_suggest) + end + + %{ + type: :generic, + kind: :property, + label: to_string(option.name), + insert_text: "#{option.name}: ", + snippet: option[:snippet], + detail: "#{fun} option", + documentation: option[:doc], + command: command + } + end + + def match_hint?(option, hint) do + option + |> Map.fetch!(:name) + |> to_string() + |> Matcher.match?(hint) + end +end diff --git a/lib/elixir_sense/providers/plugins/phoenix.ex b/lib/elixir_sense/providers/plugins/phoenix.ex new file mode 100644 index 00000000..4593dbae --- /dev/null +++ b/lib/elixir_sense/providers/plugins/phoenix.ex @@ -0,0 +1,105 @@ +defmodule ElixirSense.Providers.Plugins.Phoenix do + @moduledoc false + + @behaviour ElixirSense.Providers.Plugin + + use ElixirSense.Providers.Completion.GenericReducer + + alias ElixirSense.Core.Source + alias ElixirSense.Core.Binding + alias ElixirSense.Core.Introspection + alias ElixirSense.Providers.Plugins.ModuleStore + alias ElixirSense.Providers.Plugins.Phoenix.Scope + alias ElixirSense.Providers.Plugins.Util + alias ElixirSense.Providers.Utils.Matcher + + @phoenix_route_funcs ~w( + get put patch trace + delete head options + forward connect post + )a + + @impl true + def setup(context) do + ModuleStore.ensure_compiled(context, Phoenix.Router) + end + + if Version.match?(System.version(), ">= 1.14.0") do + @impl true + def suggestions(hint, {Phoenix.Router, func, 1, _info}, _list, opts) + when func in @phoenix_route_funcs do + binding = Binding.from_env(opts.env, opts.buffer_metadata) + {_, scope_alias} = Scope.within_scope(opts.cursor_context.text_before, binding) + + case find_controllers(opts.module_store, opts.env, hint, scope_alias) do + [] -> :ignore + controllers -> {:override, controllers} + end + end + + def suggestions( + hint, + {Phoenix.Router, func, 2, %{params: [_path, module]}}, + _list, + opts + ) + when func in @phoenix_route_funcs do + binding_env = Binding.from_env(opts.env, opts.buffer_metadata) + {_, scope_alias} = Scope.within_scope(opts.cursor_context.text_before) + {module, _} = Source.get_mod([module], binding_env) + + module = Module.concat(scope_alias, module) + + suggestions = + for {export, {2, :function}} when export not in ~w(action call)a <- + Introspection.get_exports(module), + name = inspect(export), + Matcher.match?(name, hint) do + %{ + type: :generic, + kind: :function, + label: name, + insert_text: Util.trim_leading_for_insertion(hint, name), + detail: "Phoenix action" + } + end + + {:override, suggestions} + end + end + + @impl true + def suggestions(_hint, _func_call, _list, _opts) do + :ignore + end + + defp find_controllers(module_store, env, hint, scope_alias) do + [prefix | _] = + env.module + |> inspect() + |> String.split(".") + + for module <- module_store.list, + mod_str = inspect(module), + Util.match_module?(mod_str, prefix), + mod_str =~ "Controller", + Util.match_module?(mod_str, hint) do + {doc, _} = Introspection.get_module_docs_summary(module) + + %{ + type: :generic, + kind: :class, + label: mod_str, + insert_text: skip_scope_alias(scope_alias, mod_str), + detail: "Phoenix controller", + documentation: doc + } + end + |> Enum.sort_by(& &1.label) + end + + defp skip_scope_alias(nil, insert_text), do: insert_text + + defp skip_scope_alias(scope_alias, insert_text), + do: String.replace_prefix(insert_text, "#{inspect(scope_alias)}.", "") +end diff --git a/lib/elixir_sense/providers/plugins/phoenix/scope.ex b/lib/elixir_sense/providers/plugins/phoenix/scope.ex new file mode 100644 index 00000000..75c08634 --- /dev/null +++ b/lib/elixir_sense/providers/plugins/phoenix/scope.ex @@ -0,0 +1,116 @@ +defmodule ElixirSense.Providers.Plugins.Phoenix.Scope do + @moduledoc false + + alias ElixirSense.Core.Source + alias ElixirSense.Core.Binding + + def within_scope(buffer, binding_env \\ %Binding{}) do + with {:ok, ast} <- Code.Fragment.container_cursor_to_quoted(buffer), + {true, scopes_ast} <- get_scopes(ast), + scopes_ast = Enum.reverse(scopes_ast), + scope_alias <- get_scope_alias(scopes_ast, binding_env) do + {true, scope_alias} + else + _ -> {false, nil} + end + end + + defp get_scopes(ast) do + path = Macro.path(ast, &match?({:__cursor__, _, _}, &1)) + + scopes = + path + |> Enum.filter(&match?({:scope, _, _}, &1)) + |> Enum.map(fn {:scope, meta, params} -> + params = Enum.reject(params, &match?([{:do, _} | _], &1)) + {:scope, meta, params} + end) + + case scopes do + [] -> {false, nil} + scopes -> {true, scopes} + end + end + + # scope path: "/", alias: ExampleWeb do ... end + defp get_scope_alias_from_ast_node({:scope, _, [scope_params]}, binding_env, module) + when is_list(scope_params) do + scope_alias = Keyword.get(scope_params, :alias) + concat_module(scope_alias, binding_env, module) + end + + # scope "/", alias: ExampleWeb do ... end + defp get_scope_alias_from_ast_node( + {:scope, _, [_scope_path, scope_params]}, + binding_env, + module + ) + when is_list(scope_params) do + scope_alias = Keyword.get(scope_params, :alias) + concat_module(scope_alias, binding_env, module) + end + + defp get_scope_alias_from_ast_node( + {:scope, _, [_scope_path, scope_alias]}, + binding_env, + module + ) do + concat_module(scope_alias, binding_env, module) + end + + # scope "/", ExampleWeb, host: "api." do ... end + defp get_scope_alias_from_ast_node( + {:scope, _, [_scope_path, scope_alias, scope_params]}, + binding_env, + module + ) + when is_list(scope_params) do + concat_module(scope_alias, binding_env, module) + end + + defp get_scope_alias_from_ast_node( + _ast, + _binding_env, + module + ), + do: module + + # no alias - propagate parent + defp concat_module(nil, _binding_env, module), do: module + # alias: false resets all nested aliases + defp concat_module(false, _binding_env, _module), do: nil + + defp concat_module(scope_alias, binding_env, module) do + scope_alias = get_mod(scope_alias, binding_env) + Module.concat([module, scope_alias]) + end + + defp get_scope_alias(scopes_ast, binding_env, module \\ nil) + # recurse + defp get_scope_alias([], _binding_env, module), do: module + + defp get_scope_alias([head | tail], binding_env, module) do + scope_alias = get_scope_alias_from_ast_node(head, binding_env, module) + get_scope_alias(tail, binding_env, scope_alias) + end + + defp get_mod({:__aliases__, _, [scope_alias]}, binding_env) do + get_mod(scope_alias, binding_env) + end + + defp get_mod({name, _, nil}, binding_env) when is_atom(name) do + case Binding.expand(binding_env, {:variable, name}) do + {:atom, atom} -> + atom + + _ -> + nil + end + end + + defp get_mod(scope_alias, binding_env) do + with {mod, _} <- Source.get_mod([scope_alias], binding_env) do + mod + end + end +end diff --git a/lib/elixir_sense/providers/plugins/plugin.ex b/lib/elixir_sense/providers/plugins/plugin.ex new file mode 100644 index 00000000..7ceaf86a --- /dev/null +++ b/lib/elixir_sense/providers/plugins/plugin.ex @@ -0,0 +1,27 @@ +defmodule ElixirSense.Providers.Plugin do + alias ElixirSense.Core.Metadata + alias ElixirSense.Core.State + @type suggestion :: ElixirSense.Providers.Completion.Suggestion.generic() + + @type context :: term + @type acc :: %{context: context(), result: list(suggestion())} + @type cursor_context :: %{ + text_before: String.t(), + text_after: String.t(), + at_module_body?: boolean + } + + @callback reduce( + hint :: String, + env :: State.Env.t(), + buffer_metadata :: Metadata.t(), + cursor_context, + acc + ) :: {:cont, acc} | {:halt, acc} + + @callback setup(context()) :: context() + + @callback decorate(suggestion) :: suggestion + + @optional_callbacks decorate: 1, reduce: 5, setup: 1 +end diff --git a/lib/elixir_sense/providers/plugins/util.ex b/lib/elixir_sense/providers/plugins/util.ex new file mode 100644 index 00000000..bd1e2b75 --- /dev/null +++ b/lib/elixir_sense/providers/plugins/util.ex @@ -0,0 +1,86 @@ +defmodule ElixirSense.Providers.Plugins.Util do + @moduledoc false + + alias ElixirSense.Core.Binding + alias ElixirSense.Core.Introspection + alias ElixirSense.Core.Metadata + alias ElixirSense.Core.Source + alias ElixirSense.Core.State + alias ElixirSense.Providers.Utils.Matcher + + def match_module?(mod_str, hint) do + hint = String.downcase(hint) + mod_full = String.downcase(mod_str) + mod_last = mod_full |> String.split(".") |> List.last() + Enum.any?([mod_last, mod_full], &Matcher.match?(&1, hint)) + end + + def trim_leading_for_insertion(hint, value) do + [_, hint_prefix] = Regex.run(~r/(.*?)[\w0-9\._!\?\->]*$/u, hint) + insert_text = String.replace_prefix(value, hint_prefix, "") + + case String.split(hint, ".") do + [] -> + insert_text + + hint_parts -> + parts = String.split(insert_text, ".") + {_, insert_parts} = Enum.split(parts, length(hint_parts) - 1) + Enum.join(insert_parts, ".") + end + end + + # TODO this is vscode specific. Remove? + def command(:trigger_suggest) do + %{ + "title" => "Trigger Parameter Hint", + "command" => "editor.action.triggerSuggest" + } + end + + def actual_mod_fun({mod, fun}, elixir_prefix, env, buffer_metadata) do + %Metadata{mods_funs_to_positions: mods_funs, types: metadata_types} = buffer_metadata + + Introspection.actual_mod_fun( + {mod, fun}, + env, + mods_funs, + metadata_types, + # we don't expect local macros here, no need to pass position + {1, 1}, + not elixir_prefix + ) + end + + def partial_func_call(code, %State.Env{} = env, %Metadata{} = buffer_metadata) do + binding_env = Binding.from_env(env, buffer_metadata) + + func_info = Source.which_func(code, binding_env) + + with %{candidate: {mod, fun}, npar: npar} <- func_info, + mod_fun <- actual_mod_fun({mod, fun}, func_info.elixir_prefix, env, buffer_metadata), + {actual_mod, actual_fun, _, _} <- mod_fun do + {actual_mod, actual_fun, npar, func_info} + else + _ -> + :none + end + end + + def func_call_chain(code, env, buffer_metadata) do + func_call_chain(code, env, buffer_metadata, []) + end + + # TODO reimplement this on elixir 1.14 with + # Code.Fragment.container_cursor_to_quoted and Macro.path + defp func_call_chain(code, env, buffer_metadata, chain) do + case partial_func_call(code, env, buffer_metadata) do + :none -> + Enum.reverse(chain) + + {_mod, _fun, _npar, %{pos: {{line, col}, _}}} = func_call -> + code_before = Source.text_before(code, line, col) + func_call_chain(code_before, env, buffer_metadata, [func_call | chain]) + end + end +end diff --git a/lib/elixir_sense/providers/references/locator.ex b/lib/elixir_sense/providers/references/locator.ex new file mode 100644 index 00000000..1de5b900 --- /dev/null +++ b/lib/elixir_sense/providers/references/locator.ex @@ -0,0 +1,435 @@ +defmodule ElixirSense.Providers.References.Locator do + @moduledoc """ + This module provides References to function or module identified at the provided location. + """ + + alias ElixirSense.Core.Binding + require ElixirSense.Core.Introspection, as: Introspection + alias ElixirSense.Core.Metadata + alias ElixirSense.Core.Normalized.Code, as: NormalizedCode + alias ElixirSense.Core.State + alias ElixirSense.Core.State.AttributeInfo + alias ElixirSense.Core.State.VarInfo + alias ElixirSense.Core.SurroundContext + alias ElixirSense.Core.Parser + + def references(code, line, column, trace, options \\ []) do + case NormalizedCode.Fragment.surround_context(code, {line, column}) do + :none -> + [] + + %{ + begin: {begin_line, begin_col} + } = context -> + metadata = + Keyword.get_lazy(options, :metadata, fn -> + Parser.parse_string(code, true, true, {line, column}) + end) + + env = + %State.Env{ + module: module + } = + Metadata.get_env(metadata, {line, column}) + |> Metadata.add_scope_vars(metadata, {line, column}) + + # find last env of current module + attributes = get_attributes(metadata, module) + + # one line can contain variables from many scopes + # if the cursor is over variable take variables from the scope as it will + # be more correct than the env scope + vars = + case Enum.find(env.vars, fn %VarInfo{positions: positions} -> + {begin_line, begin_col} in positions + end) do + %VarInfo{scope_id: scope_id} -> + # in (h|l)?eex templates vars_info_per_scope_id[scope_id] is nil + if metadata.vars_info_per_scope_id[scope_id] do + metadata.vars_info_per_scope_id[scope_id] + else + [] + end + + nil -> + [] + end + + find( + context, + env, + vars, + attributes, + metadata, + trace + ) + end + end + + defp get_attributes(_metadata, nil), do: [] + + defp get_attributes(metadata, module) do + %State.Env{attributes: attributes} = Metadata.get_last_module_env(metadata, module) + + attributes + end + + @type position :: %{line: pos_integer, column: pos_integer} + + @type range :: %{ + start: position, + end: position + } + + @type reference_info :: %{ + uri: String.t() | nil, + range: range + } + + def find( + context, + %State.Env{ + aliases: aliases, + module: module + } = env, + vars, + attributes, + %Metadata{ + mods_funs_to_positions: mods_funs, + calls: calls, + types: metadata_types + } = metadata, + trace + ) do + binding_env = Binding.from_env(env, metadata) + + type = SurroundContext.to_binding(context.context, module) + + refs_for_mod_fun = fn {mod, function} -> + actual = + {mod, function} + |> expand(binding_env, module, aliases) + |> Introspection.actual_mod_fun( + env, + mods_funs, + metadata_types, + context.begin, + false + ) + + case actual do + {mod, fun, true, :mod_fun} -> + {line, column} = context.end + call_arity = Metadata.get_call_arity(metadata, module, function, line, column) || :any + + metadata_call_references = + calls + |> Map.values() + |> List.flatten() + |> Enum.filter(fn call -> function == nil or call.func == function end) + |> Enum.map(fn call -> + env = Metadata.get_env(metadata, call.position) + + binding_env = Binding.from_env(env, metadata) + + found = + {call.mod, function} + |> wrap_atom + |> expand(binding_env, env.module, env.aliases) + |> Introspection.actual_mod_fun( + env, + mods_funs, + metadata_types, + call.position, + false + ) + + case found do + {^mod, ^function, true, :mod_fun} when is_nil(function) -> + build_var_location(to_string(call.func), call.position) + + {^mod, ^function, true, :mod_fun} -> + [_, _, arities] = get_matching_arities([mod, function, call_arity], mods_funs) + corrected_arity = get_corrected_arity([mod, function, call.arity], mods_funs) + + if Enum.any?( + arities, + &Introspection.matches_arity?(corrected_arity, &1) + ) do + build_var_location(to_string(function), call.position) + end + + _ -> + nil + end + end) + |> Enum.filter(&(not is_nil(&1))) + + tracer_call_reverences = + {mod, fun} + |> xref_at_cursor(call_arity, mods_funs, trace) + |> Enum.map(&build_location/1) + + (metadata_call_references ++ tracer_call_reverences) + |> Enum.sort_by(fn %{uri: a, range: %{start: %{line: b, column: c}}} -> {a, b, c} end) + + _ -> + # no results for types or not found + [] + end + end + + case type do + nil -> + [] + + {:keyword, _} -> + [] + + {:variable, variable} -> + {line, column} = context.begin + + var_info = + Enum.find(vars, fn %VarInfo{name: name, positions: positions} -> + name == variable and {line, column} in positions + end) + + if var_info != nil do + %VarInfo{positions: positions} = var_info + + positions + |> Enum.map(fn pos -> build_var_location(to_string(variable), pos) end) + else + refs_for_mod_fun.({nil, variable}) + end + + {:attribute, attribute} -> + attribute_info = + attributes + |> Enum.find(fn %AttributeInfo{name: name} -> name == attribute end) + + if attribute_info != nil do + %AttributeInfo{positions: positions} = attribute_info + + positions + |> Enum.map(fn pos -> build_var_location("@#{attribute}", pos) end) + else + [] + end + + {mod, function} -> + refs_for_mod_fun.({mod, function}) + end + end + + defp xref_at_cursor(actual_mod_fun, arity, mods_funs, trace) do + mfa = callee_at_cursor(actual_mod_fun, arity) + + filtered_calls(mfa, mods_funs, trace) + end + + # Cursor over a module + defp callee_at_cursor({module, nil}, _arity) do + [module] + end + + # Cursor over a function call + defp callee_at_cursor({module, func}, arity) do + [module, func, arity] + end + + defp filtered_calls(mfa, mods_funs, trace) do + mfa = get_matching_arities(mfa, mods_funs) + + trace + |> Map.values() + |> List.flatten() + |> Enum.filter(caller_filter(mfa, mods_funs)) + |> Enum.uniq() + end + + defp caller_filter([module, func, filter_arities], mods_funs) do + fn + %{callee: {^module, ^func, callee_arity}} -> + corrected_arity = get_corrected_arity([module, func, callee_arity], mods_funs) + Enum.any?(filter_arities, &Introspection.matches_arity?(corrected_arity, &1)) + + _ -> + false + end + end + + defp caller_filter([module, func], _mods_funs), do: &match?(%{callee: {^module, ^func, _}}, &1) + defp caller_filter([module], _mods_funs), do: &match?(%{callee: {^module, _, _}}, &1) + + defp build_location(call) do + %{callee: {_, func, _}} = call + + line = call.line || 1 + + {start_column, end_column} = + if call.line != nil and call.column != nil do + func_length = func |> to_string() |> String.length() + {call.column, call.column + func_length} + else + {1, 1} + end + + %{ + uri: call.file, + range: %{ + start: %{line: line, column: start_column}, + end: %{line: line, column: end_column} + } + } + end + + defp build_var_location(subject, {line, column}) do + %{ + uri: nil, + range: %{ + start: %{line: line, column: column}, + end: %{line: line, column: column + String.length(subject)} + } + } + end + + defp expand({nil, func}, _env, module, _aliases) when module != nil, + do: {nil, func} + + defp expand({type, func}, env, _module, aliases) do + case Binding.expand(env, type) do + {:atom, module} -> {Introspection.expand_alias(module, aliases), func} + _ -> {nil, nil} + end + end + + defp get_corrected_arity([m], _mods_funs) do + [m] + end + + defp get_corrected_arity([m, f, a], mods_funs) do + arity = + mods_funs + |> Enum.find_value(fn + {{^m, ^f, arity}, info} when not is_nil(arity) -> + # no need to filter public only here + defaults = info.params |> List.last() |> Introspection.count_defaults() + + if Introspection.matches_arity_with_defaults?(arity, defaults, a) do + arity + end + + _ -> + false + end) + + arity = + if arity != nil do + arity + else + case NormalizedCode.get_docs(m, :docs) do + nil -> + nil + + docs -> + docs + |> Enum.find_value(fn + {{^f, arity}, _, _, _, _, meta} -> + defaults = Map.get(meta, :defaults, 0) + + if Introspection.matches_arity_with_defaults?(arity, defaults, a) do + arity + end + + _ -> + false + end) + end + end + + if arity != nil do + arity + else + # no need to drop macro prefix and correct arity - macros handled by docs + if Code.ensure_loaded?(m) and {f, a} in m.module_info(:exports) do + a + end + end + end + + defp get_matching_arities([m, f, a], mods_funs) do + arities = + mods_funs + |> Enum.filter(fn + {{^m, ^f, arity}, info} when not is_nil(arity) -> + # no need to filter public only here + defaults = info.params |> List.last() |> Introspection.count_defaults() + + if Introspection.matches_arity_with_defaults?(arity, defaults, a) do + arity + end + + _ -> + false + end) + |> Enum.map(fn + {{_, _, arity}, _} -> arity + end) + + arities = + if arities == [] do + case NormalizedCode.get_docs(m, :docs) do + nil -> + [] + + docs -> + docs + |> Enum.filter(fn + {{^f, arity}, _, _, _, _, meta} -> + defaults = Map.get(meta, :defaults, 0) + + if Introspection.matches_arity_with_defaults?(arity, defaults, a) do + arity + end + + _ -> + false + end) + |> Enum.map(fn + {{^f, arity}, _, _, _, _, _meta} -> arity + end) + end + else + arities + end + + arities = + if arities == [] do + if Code.ensure_loaded?(m) do + m.module_info(:exports) + |> Enum.filter(fn {fun, arity} -> + # no need to drop macro prefix and correct arity - macros handled by docs + fun == f and Introspection.matches_arity?(arity, a) + end) + |> Enum.map(fn {_fun, arity} -> + arity + end) + else + [] + end + else + arities + end + + [m, f, arities] + end + + defp get_matching_arities(other, _mods_funs) do + other + end + + defp wrap_atom({nil, other}), do: {nil, other} + defp wrap_atom({atom, other}) when is_atom(atom), do: {{:atom, atom}, other} + defp wrap_atom(other), do: other +end diff --git a/lib/elixir_sense/providers/signature_help/signature.ex b/lib/elixir_sense/providers/signature_help/signature.ex new file mode 100644 index 00000000..62c9c3a0 --- /dev/null +++ b/lib/elixir_sense/providers/signature_help/signature.ex @@ -0,0 +1,174 @@ +defmodule ElixirSense.Providers.SignatureHelp.Signature do + @moduledoc """ + Provider responsible for introspection information about function signatures. + """ + + alias ElixirSense.Core.Binding + alias ElixirSense.Core.Introspection + alias ElixirSense.Core.Metadata + alias ElixirSense.Core.Source + alias ElixirSense.Core.State + alias ElixirSense.Core.TypeInfo + alias ElixirSense.Core.Parser + + @type signature_info :: %{ + active_param: non_neg_integer, + signatures: [Metadata.signature_t()] + } + + def signature(code, line, column, options \\ []) do + prefix = Source.text_before(code, line, column) + + metadata = + Keyword.get_lazy(options, :metadata, fn -> + Parser.parse_string(code, true, true, {line, column}) + end) + + env = Metadata.get_env(metadata, {line, column}) + + find(prefix, {line, column}, env, metadata) + end + + @doc """ + Returns the signature info from the function or type defined in the prefix, if any. + """ + @spec find(String.t(), {pos_integer, pos_integer}, State.Env.t(), Metadata.t()) :: + signature_info | :none + def find(prefix, cursor_position, env, metadata) do + binding_env = Binding.from_env(env, metadata) + + with %{candidate: {m, f}, npar: npar, elixir_prefix: elixir_prefix} <- + Source.which_func(prefix, binding_env), + {mod, fun, true, kind} <- + Introspection.actual_mod_fun( + {m, f}, + env, + metadata.mods_funs_to_positions, + metadata.types, + cursor_position, + not elixir_prefix + ) do + signatures = find_signatures({mod, fun}, npar, kind, env, metadata) + + %{active_param: npar, signatures: signatures} + else + _ -> + :none + end + end + + defp find_signatures({mod, fun}, npar, kind, env, metadata) do + signatures = + case kind do + :mod_fun -> find_function_signatures({mod, fun}, env, metadata) + :type -> find_type_signatures({mod, fun}, metadata) + end + + signatures + |> Enum.map(fn + %{params: []} = signature -> + if npar == 0 do + signature + end + + %{params: params} = signature -> + defaults = + params + |> Enum.with_index() + |> Enum.map(fn {param, index} -> {Regex.match?(~r/\\\\/u, param), index} end) + |> Enum.sort() + |> Enum.at(npar) + + case defaults do + nil -> nil + {_, index} when index != npar -> Map.put(signature, :active_param, index) + _ -> signature + end + end) + |> Enum.reject(&is_nil/1) + |> Enum.sort_by(&{length(&1.params), -Map.get(&1, :active_param, npar)}) + end + + defp find_function_signatures({nil, _fun}, _env, _metadata), do: [] + + defp find_function_signatures({mod, fun}, env, metadata) do + signatures = + case Metadata.get_function_signatures(metadata, mod, fun) do + [] -> + Introspection.get_signatures(mod, fun) + + signatures -> + for signature <- signatures do + arity = length(signature.params) + + behaviour_implementation = + Metadata.get_module_behaviours(metadata, env, mod) + |> Enum.find_value(fn behaviour -> + if Introspection.is_callback(behaviour, fun, arity, metadata) do + behaviour + end + end) + + case behaviour_implementation do + nil -> + signature + + behaviour -> + case metadata.specs[{behaviour, fun, arity}] do + %State.SpecInfo{} = spec_info -> + specs = + spec_info.specs + |> Enum.reject(&String.starts_with?(&1, "@spec")) + |> Enum.reverse() + + callback_doc = + case metadata.mods_funs_to_positions[{behaviour, fun, arity}] do + nil -> + spec_info.doc + + def_info -> + # in case of protocol implementation get doc and meta from def + def_info.doc + end + + %{ + signature + | spec: specs |> Enum.join("\n"), + documentation: Introspection.extract_summary_from_docs(callback_doc) + } + + nil -> + fun_info = Map.fetch!(metadata.mods_funs_to_positions, {mod, fun, arity}) + + {spec, doc, _} = + Metadata.get_doc_spec_from_behaviour( + behaviour, + fun, + arity, + State.ModFunInfo.get_category(fun_info) + ) + + %{ + signature + | documentation: Introspection.extract_summary_from_docs(doc), + spec: spec + } + end + end + end + end + + signatures |> Enum.uniq_by(fn sig -> sig.params end) + end + + defp find_type_signatures({nil, fun}, _metadata) do + TypeInfo.get_signatures(nil, fun) + end + + defp find_type_signatures({mod, fun}, metadata) do + case Metadata.get_type_signatures(metadata, mod, fun) do + [] -> TypeInfo.get_signatures(mod, fun) + signature -> signature + end + end +end diff --git a/lib/elixir_sense/providers/utils/field.ex b/lib/elixir_sense/providers/utils/field.ex new file mode 100644 index 00000000..52ab7655 --- /dev/null +++ b/lib/elixir_sense/providers/utils/field.ex @@ -0,0 +1,62 @@ +defmodule ElixirSense.Providers.Utils.Field do + alias ElixirSense.Core.Metadata + alias ElixirSense.Core.State + alias ElixirSense.Core.Normalized.Typespec + alias ElixirSense.Core.TypeInfo + + def get_field_types(%Metadata{} = metadata, mod, include_private) when is_atom(mod) do + case get_field_types_from_metadata(metadata, mod, include_private) do + nil -> get_field_types_from_introspection(mod, include_private) + res -> res + end + end + + defguardp type_is_public(kind, include_private) when kind == :type or include_private + + defp get_field_types_from_metadata( + %Metadata{types: types}, + mod, + include_private + ) do + case types[{mod, :t, 0}] do + %State.TypeInfo{specs: [type_spec], kind: kind} + when type_is_public(kind, include_private) -> + case Code.string_to_quoted(type_spec) do + {:ok, {:@, _, [{_kind, _, [spec]}]}} -> + spec + |> get_fields_from_struct_spec() + + _ -> + nil + end + + _ -> + nil + end + end + + defp get_field_types_from_introspection(nil, _include_private), do: %{} + + defp get_field_types_from_introspection(mod, include_private) when is_atom(mod) do + # assume struct typespec is t() + case TypeInfo.get_type_spec(mod, :t, 0) do + {kind, spec} when type_is_public(kind, include_private) -> + spec + |> Typespec.type_to_quoted() + |> get_fields_from_struct_spec() + + _ -> + %{} + end + end + + defp get_fields_from_struct_spec({:"::", _, [_, {:%, _meta1, [_mod, {:%{}, _meta2, fields}]}]}) do + if Keyword.keyword?(fields) do + Map.new(fields) + else + %{} + end + end + + defp get_fields_from_struct_spec(_), do: %{} +end diff --git a/lib/elixir_sense/providers/utils/matcher.ex b/lib/elixir_sense/providers/utils/matcher.ex new file mode 100644 index 00000000..5558e38b --- /dev/null +++ b/lib/elixir_sense/providers/utils/matcher.ex @@ -0,0 +1,72 @@ +defmodule ElixirSense.Providers.Utils.Matcher do + @moduledoc """ + ## Suggestion Matching + """ + + import Kernel, except: [match?: 2] + + @doc """ + Naive sequential fuzzy matching without weight and requiring first char to match. + + ## Examples + + iex> ElixirLS.Utils.Matcher.match?("map", "map") + true + + iex> ElixirLS.Utils.Matcher.match?("map", "m") + true + + iex> ElixirLS.Utils.Matcher.match?("map", "ma") + true + + iex> ElixirLS.Utils.Matcher.match?("map", "mp") + true + + iex> ElixirLS.Utils.Matcher.match?("map", "") + true + + iex> ElixirLS.Utils.Matcher.match?("map", "ap") + false + + iex> ElixirLS.Utils.Matcher.match?("", "") + true + + iex> ElixirLS.Utils.Matcher.match?("chunk_by", "chub") + true + + iex> ElixirLS.Utils.Matcher.match?("chunk_by", "chug") + false + """ + @spec match?(name :: String.t(), hint :: String.t()) :: boolean() + def match?(<>, <>) + when name_head != hint_head do + false + end + + def match?(name, hint) do + do_match?(name, hint) + end + + defp do_match?(<>, <>) do + do_match?(name_rest, hint_rest) + end + + defp do_match?( + <<_head::utf8, name_rest::binary>>, + <<_not_head::utf8, _hint_rest::binary>> = hint + ) do + do_match?(name_rest, hint) + end + + defp do_match?(_name_rest, <<>>) do + true + end + + defp do_match?(<<>>, <<>>) do + true + end + + defp do_match?(<<>>, _) do + false + end +end