From a9ce933f65ead29523ddac097afa914c496340c1 Mon Sep 17 00:00:00 2001 From: Mitchell Hanberg Date: Sat, 30 Sep 2023 09:56:44 -0400 Subject: [PATCH] feat: completions wip --- lib/next_ls.ex | 72 ++ lib/next_ls/autocomplete.ex | 879 ++++++++++++++++++ lib/next_ls/db.ex | 10 +- lib/next_ls/helpers/ast_helpers/variables.ex | 30 +- lib/next_ls/runtime.ex | 26 + lib/next_ls/snippet.ex | 189 ++++ test/next_ls/autocomplete_test.exs | 622 +++++++++++++ .../helpers/ast_helpers/variables_test.exs | 2 +- 8 files changed, 1824 insertions(+), 6 deletions(-) create mode 100644 lib/next_ls/autocomplete.ex create mode 100644 lib/next_ls/snippet.ex create mode 100644 test/next_ls/autocomplete_test.exs diff --git a/lib/next_ls.ex b/lib/next_ls.ex index 2d78b242..02206aa7 100644 --- a/lib/next_ls.ex +++ b/lib/next_ls.ex @@ -16,6 +16,7 @@ defmodule NextLS do alias GenLSP.Notifications.WorkspaceDidChangeWorkspaceFolders alias GenLSP.Requests.Initialize alias GenLSP.Requests.Shutdown + alias GenLSP.Requests.TextDocumentCompletion alias GenLSP.Requests.TextDocumentDefinition alias GenLSP.Requests.TextDocumentDocumentSymbol alias GenLSP.Requests.TextDocumentFormatting @@ -117,6 +118,9 @@ defmodule NextLS do save: %SaveOptions{include_text: true}, change: TextDocumentSyncKind.full() }, + completion_provider: %GenLSP.Structures.CompletionOptions{ + trigger_characters: [".", "@", "&", "%", "^", ":", "!", "-", "~"] + }, document_formatting_provider: true, hover_provider: true, workspace_symbol_provider: true, @@ -504,6 +508,74 @@ defmodule NextLS do resp end + def handle_request(%TextDocumentCompletion{params: %{text_document: %{uri: uri}, position: position}}, lsp) do + document = lsp.assigns.documents[uri] + + document_slice = + document + |> Enum.take(position.line + 1) + |> Enum.reverse() + |> then(fn [last_line | rest] -> + [String.slice(last_line, 1..(position.character + 1)) | rest] + end) + |> Enum.reverse() + |> Enum.join("\n") + + results = + lsp.assigns.registry + |> dispatch(:runtimes, fn entries -> + [result] = + for {runtime, %{uri: wuri}} <- entries, String.starts_with?(uri, wuri) do + NextLS.Autocomplete.expand(document_slice |> String.to_charlist() |> Enum.reverse(), runtime) + end + + case result do + {:yes, _, entries} -> entries + _ -> [] + end + end) + |> Enum.map(fn %{name: name, kind: kind} = symbol -> + {label, kind, docs} = + case kind do + :struct -> {name, GenLSP.Enumerations.CompletionItemKind.struct(), ""} + :function -> {"#{name}/#{symbol.arity}", GenLSP.Enumerations.CompletionItemKind.function(), symbol.docs} + :module -> {name, GenLSP.Enumerations.CompletionItemKind.module(), ""} + :variable -> {name, GenLSP.Enumerations.CompletionItemKind.variable(), ""} + _ -> {name, GenLSP.Enumerations.CompletionItemKind.text(), ""} + end + + %GenLSP.Structures.CompletionItem{ + label: label, + kind: kind, + insert_text: name, + documentation: docs + } + end) + + # results = + # for snippet <- [ + # "defmodule", + # "def", + # "defp", + # "defmacro", + # "defmacrop", + # "for", + # "with", + # "case", + # "cond", + # "defprotocol", + # "defimpl", + # "defexception", + # "defstruct" + # ], + # item <- List.wrap(NextLS.Snippet.get(snippet, context.trigger_character)), + # item do + # item + # end + + {:reply, results, lsp} + end + def handle_request(%Shutdown{}, lsp) do {:reply, nil, assign(lsp, exit_code: 0)} end diff --git a/lib/next_ls/autocomplete.ex b/lib/next_ls/autocomplete.ex new file mode 100644 index 00000000..dc510db6 --- /dev/null +++ b/lib/next_ls/autocomplete.ex @@ -0,0 +1,879 @@ +defmodule NextLS.Autocomplete do + @moduledoc false + + require NextLS.Runtime + + @bitstring_modifiers [ + %{kind: :variable, name: "big"}, + %{kind: :variable, name: "binary"}, + %{kind: :variable, name: "bitstring"}, + %{kind: :variable, name: "integer"}, + %{kind: :variable, name: "float"}, + %{kind: :variable, name: "little"}, + %{kind: :variable, name: "native"}, + %{kind: :variable, name: "signed"}, + %{kind: :function, name: "size", arity: 1}, + %{kind: :function, name: "unit", arity: 1}, + %{kind: :variable, name: "unsigned"}, + %{kind: :variable, name: "utf8"}, + %{kind: :variable, name: "utf16"}, + %{kind: :variable, name: "utf32"} + ] + + @alias_only_atoms ~w(alias import require)a + @alias_only_charlists ~w(alias import require)c + + @doc """ + The expansion logic. + + Some of the expansion has to be use the current shell + environment, which is found via the broker. + """ + def expand(code, shell) do + case path_fragment(code) do + [] -> expand_code(code, shell) + path -> expand_path(path) + end + end + + defp expand_code(code, shell) do + code = Enum.reverse(code) + helper = get_helper(code) + + case Code.Fragment.cursor_context(code) do + {:alias, alias} -> + expand_aliases(List.to_string(alias), shell) + + {:unquoted_atom, unquoted_atom} -> + expand_erlang_modules(List.to_string(unquoted_atom), shell) + + expansion when helper == ?b -> + expand_typespecs(expansion, shell, &get_module_callbacks(&1, shell)) + + expansion when helper == ?t -> + expand_typespecs(expansion, shell, &get_module_types(&1, shell)) + + {:dot, path, hint} -> + if alias = alias_only(path, hint, code, shell) do + dbg(alias) + expand_aliases(List.to_string(alias), shell) + else + dbg(hint) + expand_dot(path, List.to_string(hint), false, shell) + end + + {:dot_arity, path, hint} -> + expand_dot(path, List.to_string(hint), true, shell) + + {:dot_call, path, hint} -> + path |> expand_dot_call(List.to_atom(hint), shell) |> dbg() + + :expr -> + expand_container_context(code, :expr, "", shell) || expand_local_or_var("", "", shell) + + {:local_or_var, local_or_var} -> + hint = List.to_string(code) + + expand_container_context(code, :expr, hint, shell) || + expand_local_or_var(hint, List.to_string(local_or_var), shell) + + {:local_arity, local} -> + expand_local(List.to_string(local), true, shell) + + {:local_call, local} when local in @alias_only_charlists -> + expand_aliases("", shell) + + {:local_call, local} -> + expand_local_call(List.to_atom(local), shell) + + {:operator, operator} when operator in ~w(:: -)c -> + expand_container_context(code, :operator, "", shell) || + expand_local(List.to_string(operator), false, shell) + + {:operator, operator} -> + expand_local(List.to_string(operator), false, shell) + + {:operator_arity, operator} -> + expand_local(List.to_string(operator), true, shell) + + {:operator_call, operator} when operator in ~w(|)c -> + expand_container_context(code, :expr, "", shell) || expand_local_or_var("", "", shell) + + {:operator_call, _operator} -> + expand_local_or_var("", "", shell) + + {:sigil, []} -> + expand_sigil(shell) + + {:sigil, [_]} -> + {:yes, [], ~w|" """ ' ''' \( / < [ { \||c} + + {:struct, struct} when is_list(struct) -> + expand_structs(List.to_string(struct), shell) + + {:struct, {:dot, {:alias, struct}, ~c""}} when is_list(struct) -> + expand_structs(List.to_string(struct ++ ~c"."), shell) + + # {:module_attribute, charlist} + # :none + _ -> + no() + end + end + + defp get_helper(expr) do + with [helper | rest] when helper in ~c"bt" <- expr, + [space_or_paren, char | _] <- squeeze_spaces(rest), + true <- + space_or_paren in ~c" (" and + (char in ?A..?Z or char in ?a..?z or char in ?0..?9 or char in ~c"_:") do + helper + else + _ -> nil + end + end + + defp squeeze_spaces(~c" " ++ rest), do: squeeze_spaces([?\s | rest]) + defp squeeze_spaces(rest), do: rest + + @doc false + def exports(mod, shell) do + {:ok, exported?} = NextLS.Runtime.execute(shell, do: Kernel.function_exported?(mod, :__info__, 1)) + + if ensure_loaded?(mod, shell) and exported? do + mod.__info__(:macros) ++ (mod.__info__(:functions) -- [__info__: 1]) + else + mod.module_info(:exports) -- [module_info: 0, module_info: 1] + end + end + + ## Typespecs + + defp expand_typespecs({:dot, path, hint}, shell, fun) do + hint = List.to_string(hint) + + case expand_dot_path(path, shell) do + {:ok, mod} when is_atom(mod) -> + mod + |> fun.() + |> then(&match_module_funs(shell, mod, &1, hint, false)) + |> format_expansion(hint) + + _ -> + no() + end + end + + defp expand_typespecs(_, _, _), do: no() + + ## Expand call + + defp expand_local_call(fun, shell) do + shell + |> imports_from_env() + |> Enum.filter(fn {_, funs} -> List.keymember?(funs, fun, 0) end) + |> Enum.flat_map(fn {module, _} -> get_signatures(fun, module) end) + |> expand_signatures(shell) + end + + defp expand_dot_call(path, fun, shell) do + case expand_dot_path(path, shell) do + {:ok, mod} when is_atom(mod) -> fun |> get_signatures(mod) |> expand_signatures(shell) + _ -> no() + end + end + + defp get_signatures(name, module) when is_atom(module) do + with docs when is_list(docs) <- get_docs(module, [:function, :macro], name) do + Enum.map(docs, fn {_, _, signatures, _, _} -> Enum.join(signatures, " ") end) + else + _ -> [] + end + end + + defp expand_signatures([_ | _] = signatures, _shell) do + [head | tail] = Enum.sort(signatures, &(String.length(&1) <= String.length(&2))) + if tail != [], do: IO.write("\n" <> (tail |> Enum.reverse() |> Enum.join("\n"))) + yes("", [head]) + end + + defp expand_signatures([], shell), do: expand_local_or_var("", "", shell) + + ## Expand dot + + defp expand_dot(path, hint, exact?, shell) do + case expand_dot_path(path, shell) do + {:ok, mod} when is_atom(mod) and hint == "" -> expand_dot_aliases(mod, shell) + {:ok, mod} when is_atom(mod) -> expand_require(mod, hint, exact?, shell) + {:ok, map} when is_map(map) -> expand_map_field_access(map, hint) + _ -> no() + end + end + + defp expand_dot_path({:unquoted_atom, var}, _shell) do + {:ok, List.to_atom(var)} + end + + defp expand_dot_path(path, shell) do + case recur_expand_dot_path(path, shell) do + {:ok, [_ | _] = path} -> value_from_binding(Enum.reverse(path), shell) + other -> other + end + end + + defp recur_expand_dot_path({:var, var}, _shell) do + {:ok, [List.to_atom(var)]} + end + + defp recur_expand_dot_path({:alias, var}, shell) do + {:ok, var |> List.to_string() |> String.split(".") |> value_from_alias(shell)} + end + + defp recur_expand_dot_path({:dot, parent, call}, shell) do + case recur_expand_dot_path(parent, shell) do + {:ok, [_ | _] = path} -> {:ok, [List.to_atom(call) | path]} + _ -> :error + end + end + + defp recur_expand_dot_path(_, _shell) do + :error + end + + defp expand_map_field_access(map, hint) do + case match_map_fields(map, hint) do + [%{kind: :map_key, name: ^hint, value_is_map: false}] -> no() + map_fields when is_list(map_fields) -> format_expansion(map_fields, hint) + end + end + + defp expand_dot_aliases(mod, shell) do + all = match_elixir_modules(mod, "", shell) ++ match_module_funs(shell, mod, get_module_funs(mod, shell), "", false) + format_expansion(all, "") + end + + defp expand_require(mod, hint, exact?, shell) do + mod + |> get_module_funs(shell) + |> then(&match_module_funs(shell, mod, &1, hint, exact?)) + |> format_expansion(hint) + end + + ## Expand local or var + + defp expand_local_or_var(code, hint, shell) do + format_expansion(match_var(code, hint, shell) ++ match_local(code, false, shell), hint) + end + + defp expand_local(hint, exact?, shell) do + format_expansion(match_local(hint, exact?, shell), hint) + end + + defp expand_sigil(shell) do + sigils = + "sigil_" + |> match_local(false, shell) + |> Enum.map(fn %{name: "sigil_" <> rest} -> %{kind: :sigil, name: rest} end) + + format_expansion(match_local("~", false, shell) ++ sigils, "~") + end + + defp match_local(hint, exact?, shell) do + imports = shell |> imports_from_env() |> Enum.flat_map(&elem(&1, 1)) + module_funs = get_module_funs(Kernel.SpecialForms, shell) + match_module_funs(shell, nil, imports ++ module_funs, hint, exact?) + end + + defp match_var(code, hint, shell) do + code + |> variables_from_binding(shell) + |> Enum.filter(&String.starts_with?(&1, hint)) + |> Enum.sort() + |> Enum.map(&%{kind: :variable, name: &1}) + end + + ## Erlang modules + + defp expand_erlang_modules(hint, shell) do + format_expansion(match_erlang_modules(hint, shell), hint) + end + + defp match_erlang_modules(hint, shell) do + for mod <- match_modules(hint, false, shell), usable_as_unquoted_module?(mod) do + %{kind: :module, name: mod} + end + end + + ## Structs + + defp expand_structs(hint, shell) do + aliases = + for {alias, mod} <- aliases_from_env(shell), + [name] = Module.split(alias), + String.starts_with?(name, hint), + do: {mod, name} + + modules = + for "Elixir." <> name = full_name <- match_modules("Elixir." <> hint, true, shell), + String.starts_with?(name, hint), + mod = String.to_atom(full_name), + do: {mod, name} + + all = aliases ++ modules + {:ok, _} = NextLS.Runtime.execute(shell, do: Code.ensure_all_loaded(Enum.map(all, &elem(&1, 0)))) + + refs = + for {mod, name} <- all, + function_exported?(mod, :__struct__, 1) and not function_exported?(mod, :exception, 1), + do: %{kind: :struct, name: name} + + format_expansion(refs, hint) + end + + defp expand_container_context(code, context, hint, shell) do + case container_context(code, shell) do + {:map, map, pairs} when context == :expr -> + container_context_map_fields(pairs, map, hint) + + {:struct, alias, pairs} when context == :expr -> + map = Map.from_struct(alias.__struct__) + container_context_map_fields(pairs, map, hint) + + :bitstring_modifier -> + existing = + code + |> List.to_string() + |> String.split("::") + |> List.last() + |> String.split("-") + + @bitstring_modifiers + |> Enum.filter(&(String.starts_with?(&1.name, hint) and &1.name not in existing)) + |> format_expansion(hint) + + _ -> + nil + end + end + + defp container_context_map_fields(pairs, map, hint) do + pairs = + Enum.reduce(pairs, map, fn {key, _}, map -> + Map.delete(map, key) + end) + + entries = + for {key, _value} <- pairs, + name = Atom.to_string(key), + if(hint == "", + do: not String.starts_with?(name, "_"), + else: String.starts_with?(name, hint) + ), + do: %{kind: :keyword, name: name} + + format_expansion(entries, hint) + end + + defp container_context(code, shell) do + case Code.Fragment.container_cursor_to_quoted(code, columns: true) do + {:ok, quoted} -> + case Macro.path(quoted, &match?({:__cursor__, _, []}, &1)) do + [cursor, {:%{}, _, pairs}, {:%, _, [{:__aliases__, _, aliases}, _map]} | _] -> + container_context_struct(cursor, pairs, aliases, shell) + + [ + cursor, + pairs, + {:|, _, _}, + {:%{}, _, _}, + {:%, _, [{:__aliases__, _, aliases}, _map]} | _ + ] -> + container_context_struct(cursor, pairs, aliases, shell) + + [cursor, pairs, {:|, _, [{variable, _, nil} | _]}, {:%{}, _, _} | _] -> + container_context_map(cursor, pairs, variable, shell) + + [cursor, {special_form, _, [cursor]} | _] when special_form in @alias_only_atoms -> + :alias_only + + [cursor | tail] -> + case remove_operators(tail, cursor) do + [{:"::", _, [_, _]}, {:<<>>, _, [_ | _]} | _] -> :bitstring_modifier + _ -> nil + end + + _ -> + nil + end + + {:error, _} -> + nil + end + end + + defp remove_operators([{op, _, [_, previous]} = head | tail], previous) when op in [:-], + do: remove_operators(tail, head) + + defp remove_operators(tail, _previous), do: tail + + defp container_context_struct(cursor, pairs, aliases, shell) do + with {pairs, [^cursor]} <- Enum.split(pairs, -1), + alias = value_from_alias(aliases, shell), + true <- + Keyword.keyword?(pairs) and ensure_loaded?(alias, shell) and + function_exported?(alias, :__struct__, 1) do + {:struct, alias, pairs} + else + _ -> nil + end + end + + defp container_context_map(cursor, pairs, variable, shell) do + with {pairs, [^cursor]} <- Enum.split(pairs, -1), + {:ok, map} when is_map(map) <- value_from_binding([variable], shell), + true <- Keyword.keyword?(pairs) do + {:map, map, pairs} + else + _ -> nil + end + end + + ## Aliases and modules + + defp alias_only(path, hint, code, shell) do + with {:alias, alias} <- path, + [] <- hint, + :alias_only <- container_context(code, shell) do + alias ++ [?.] + else + _ -> nil + end + end + + defp expand_aliases(all, shell) do + case String.split(all, ".") do + [hint] -> + all = match_aliases(hint, shell) ++ match_elixir_modules(Elixir, hint, shell) + format_expansion(all, hint) + + parts -> + hint = List.last(parts) + list = Enum.take(parts, length(parts) - 1) + + list + |> value_from_alias(shell) + |> match_elixir_modules(hint, shell) + |> format_expansion(hint) + end + end + + defp value_from_alias([name | rest], shell) do + case Keyword.fetch(aliases_from_env(shell), Module.concat(Elixir, name)) do + {:ok, name} when rest == [] -> name + {:ok, name} -> Module.concat([name | rest]) + :error -> Module.concat([name | rest]) + end + end + + defp match_aliases(hint, shell) do + for {alias, module} <- aliases_from_env(shell), + [name] = Module.split(alias), + String.starts_with?(name, hint) do + %{kind: :module, name: name, module: module} + end + end + + defp match_elixir_modules(module, hint, shell) do + name = Atom.to_string(module) + depth = length(String.split(name, ".")) + 1 + base = name <> "." <> hint + + for mod <- match_modules(base, module == Elixir, shell), + parts = String.split(mod, "."), + depth <= length(parts), + name = Enum.at(parts, depth - 1), + valid_alias_piece?("." <> name), + uniq: true, + do: %{kind: :module, name: name} + 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) + + ## Formatting + + defp format_expansion([], _) do + no() + end + + defp format_expansion([uniq], hint) do + case to_hint(uniq, hint) do + "" -> yes("", [uniq]) + hint -> yes(hint, [uniq]) + end + end + + defp format_expansion([first | _] = entries, hint) do + binary = Enum.map(entries, & &1.name) + length = byte_size(hint) + prefix = :binary.longest_common_prefix(binary) + + if prefix in [0, length] do + yes("", entries) + else + yes(binary_part(first.name, prefix, length - prefix), entries) + end + end + + defp yes(hint, entries) do + {:yes, String.to_charlist(hint), entries} + end + + defp no do + {:no, ~c"", []} + end + + ## Helpers + + defp usable_as_unquoted_module?(name) do + # Conversion to atom is not a problem because + # it is only called with existing modules names. + Macro.classify_atom(String.to_atom(name)) in [:identifier, :unquoted] + end + + defp match_modules(hint, elixir_root?, shell) do + elixir_root? + |> get_modules(shell) + |> Enum.sort() + |> Enum.dedup() + |> Enum.drop_while(&(not String.starts_with?(&1, hint))) + |> Enum.take_while(&String.starts_with?(&1, hint)) + end + + defp get_modules(true, shell) do + ["Elixir.Elixir"] ++ get_modules(false, shell) + end + + defp get_modules(false, shell) do + {:ok, mods} = + NextLS.Runtime.execute shell do + :code.all_loaded() + end + + modules = + Enum.map(mods, &Atom.to_string(elem(&1, 0))) + + {:ok, mode} = NextLS.Runtime.execute(shell, do: :code.get_mode()) + + case mode do + :interactive -> modules ++ get_modules_from_applications(shell) + _otherwise -> modules + end + end + + defp get_modules_from_applications(shell) do + for [app] <- loaded_applications(shell), + {:ok, modules} = + then(NextLS.Runtime.execute(shell, do: :application.get_key(app, :modules)), fn {:ok, result} -> result end), + module <- modules do + Atom.to_string(module) + end + end + + defp loaded_applications(shell) do + # If we invoke :application.loaded_applications/0, + # it can error if we don't call safe_fixtable before. + # Since in both cases we are reaching over the + # application controller internals, we choose to match + # for performance. + {:ok, apps} = + NextLS.Runtime.execute shell do + :ets.match(:ac_tab, {{:loaded, :"$1"}, :_}) + end + + apps + end + + defp match_module_funs(runtime, mod, funs, hint, exact?) do + {content_type, fdocs} = + case NextLS.Runtime.execute(runtime, do: Code.fetch_docs(mod)) do + {:ok, {:docs_v1, _, _lang, content_type, _, _, fdocs}} -> + {content_type, fdocs} + + _ -> + {"text/markdown", []} + end + + for_result = + for {fun, arity} <- funs, + name = Atom.to_string(fun), + if(exact?, do: name == hint, else: String.starts_with?(name, hint)) do + doc = + Enum.find(fdocs, fn {{type, fname, _a}, _, _, _doc, _} -> + type in [:function, :macro] and to_string(fname) == name + end) + + doc = + case doc do + {_, _, _, %{"en" => fdoc}, _} -> + """ + ## #{Macro.to_string(mod)}.#{name}/#{arity} + + #{NextLS.HoverHelpers.to_markdown(content_type, fdoc)} + """ + + _ -> + nil + end + + %{ + kind: :function, + name: name, + arity: arity, + docs: doc + } + end + + Enum.sort_by(for_result, &{&1.name, &1.arity}) + end + + defp match_map_fields(map, hint) do + for_result = + for {key, value} when is_atom(key) <- Map.to_list(map), + key = Atom.to_string(key), + String.starts_with?(key, hint) do + %{kind: :map_key, name: key, value_is_map: is_map(value)} + end + + Enum.sort_by(for_result, & &1.name) + end + + defp get_module_funs(mod, shell) do + cond do + not ensure_loaded?(mod, shell) -> + [] + + docs = get_docs(mod, [:function, :macro]) -> + mod + |> exports(shell) + |> Kernel.--(default_arg_functions_with_doc_false(docs)) + |> Enum.reject(&hidden_fun?(&1, docs)) + + true -> + exports(mod, shell) + end + end + + defp get_module_types(mod, shell) do + if ensure_loaded?(mod, shell) do + case Code.Typespec.fetch_types(mod) do + {:ok, types} -> + for {kind, {name, _, args}} <- types, + kind in [:type, :opaque] do + {name, length(args)} + end + + :error -> + [] + end + else + [] + end + end + + defp get_module_callbacks(mod, shell) do + if ensure_loaded?(mod, shell) do + case Code.Typespec.fetch_callbacks(mod) do + {:ok, callbacks} -> + for {name_arity, _} <- callbacks do + {_kind, name, arity} = IEx.Introspection.translate_callback_name_arity(name_arity) + + {name, arity} + end + + :error -> + [] + end + else + [] + end + end + + defp get_docs(mod, kinds, fun \\ nil) do + case Code.fetch_docs(mod) do + {:docs_v1, _, _, _, _, _, docs} -> + if is_nil(fun) do + for {{kind, _, _}, _, _, _, _} = doc <- docs, kind in kinds, do: doc + else + for {{kind, ^fun, _}, _, _, _, _} = doc <- docs, kind in kinds, do: doc + end + + {:error, _} -> + nil + end + end + + defp default_arg_functions_with_doc_false(docs) do + for {{_, fun_name, arity}, _, _, :hidden, %{defaults: count}} <- docs, + new_arity <- (arity - count)..arity, + do: {fun_name, new_arity} + end + + defp hidden_fun?({name, arity}, docs) do + case Enum.find(docs, &match?({{_, ^name, ^arity}, _, _, _, _}, &1)) do + nil -> hd(Atom.to_charlist(name)) == ?_ + {_, _, _, :hidden, _} -> true + {_, _, _, _, _} -> false + end + end + + defp ensure_loaded?(Elixir, _shell), do: false + + defp ensure_loaded?(mod, shell) do + {:ok, value} = NextLS.Runtime.execute(shell, do: Code.ensure_loaded?(mod)) + value + end + + ## Ad-hoc conversions + + # Add extra character only if pressing tab when done + defp to_hint(%{kind: :module, name: hint}, hint) do + "." + end + + defp to_hint(%{kind: :map_key, name: hint, value_is_map: true}, hint) do + "." + end + + defp to_hint(%{kind: :file, name: hint}, hint) do + "\"" + end + + # Add extra character whenever possible + defp to_hint(%{kind: :dir, name: name}, hint) do + format_hint(name, hint) <> "/" + end + + defp to_hint(%{kind: :struct, name: name}, hint) do + format_hint(name, hint) <> "{" + end + + defp to_hint(%{kind: :keyword, name: name}, hint) do + format_hint(name, hint) <> ": " + end + + defp to_hint(%{kind: _, name: name}, hint) do + format_hint(name, hint) + end + + defp format_hint(name, hint) do + hint_size = byte_size(hint) + binary_part(name, hint_size, byte_size(name) - hint_size) + end + + ## Evaluator interface + + defp imports_from_env(_runtime) do + # with {evaluator, server} <- IEx.Broker.evaluator(shell), + # env_fields = IEx.Evaluator.fields_from_env(evaluator, server, [:functions, :macros]), + # %{functions: funs, macros: macros} <- env_fields do + # funs ++ macros + # else + # _ -> [] + # end + [] + end + + defp aliases_from_env(_runtime) do + # with {evaluator, server} <- IEx.Broker.evaluator(shell), + # %{aliases: aliases} <- IEx.Evaluator.fields_from_env(evaluator, server, [:aliases]) do + # aliases + # else + # _ -> [] + # end + [] + end + + defp variables_from_binding(hint, _runtime) do + {:ok, ast} = Code.Fragment.container_cursor_to_quoted(hint, columns: true) + + ast |> Macro.to_string() |> IO.puts() + + dbg(ast, limit: :infinity) + + NextLS.ASTHelpers.Variables.collect(ast) + end + + defp value_from_binding([_var | _path], _runtime) do + # with {evaluator, server} <- IEx.Broker.evaluator(shell) do + # IEx.Evaluator.value_from_binding(evaluator, server, var, path) + # else + # _ -> :error + # end + [] + end + + ## Path helpers + + defp path_fragment(expr), do: path_fragment(expr, []) + defp path_fragment([], _acc), do: [] + defp path_fragment([?{, ?# | _rest], _acc), do: [] + defp path_fragment([?", ?\\ | t], acc), do: path_fragment(t, [?\\, ?" | acc]) + + defp path_fragment([?/, ?:, x, ?" | _], acc) when x in ?a..?z or x in ?A..?Z, do: [x, ?:, ?/ | acc] + + defp path_fragment([?/, ?., ?" | _], acc), do: [?., ?/ | acc] + defp path_fragment([?/, ?" | _], acc), do: [?/ | acc] + defp path_fragment([?" | _], _acc), do: [] + defp path_fragment([h | t], acc), do: path_fragment(t, [h | acc]) + + defp expand_path(path) do + path + |> List.to_string() + |> ls_prefix() + |> Enum.map(fn path -> + %{ + kind: if(File.dir?(path), do: :dir, else: :file), + name: Path.basename(path) + } + end) + |> format_expansion(path_hint(path)) + end + + defp path_hint(path) do + if List.last(path) in [?/, ?\\] do + "" + else + Path.basename(path) + end + end + + defp prefix_from_dir(".", <>) when c != ?., do: "" + defp prefix_from_dir(dir, _fragment), do: dir + + defp ls_prefix(path) do + dir = Path.dirname(path) + prefix = prefix_from_dir(dir, path) + + case File.ls(dir) do + {:ok, list} -> + list + |> Enum.map(&Path.join(prefix, &1)) + |> Enum.filter(&String.starts_with?(&1, path)) + + _ -> + [] + end + end +end diff --git a/lib/next_ls/db.ex b/lib/next_ls/db.ex index 90ab1132..66e3932e 100644 --- a/lib/next_ls/db.ex +++ b/lib/next_ls/db.ex @@ -195,10 +195,12 @@ defmodule NextLS.DB do case Exqlite.Basic.exec(conn, query, args) do {:error, %{message: message, statement: statement}, _} -> NextLS.Logger.warning(logger, """ - sqlite3 error: #{message} - - statement: #{statement} - arguments: #{inspect(args)} + ┌───────────── + │sqlite3 error: #{message} + │ + │statement: #{statement} + │arguments: #{inspect(args)} + └───────────── """) {:error, message} diff --git a/lib/next_ls/helpers/ast_helpers/variables.ex b/lib/next_ls/helpers/ast_helpers/variables.ex index 1db64841..7e9772f6 100644 --- a/lib/next_ls/helpers/ast_helpers/variables.ex +++ b/lib/next_ls/helpers/ast_helpers/variables.ex @@ -19,6 +19,7 @@ defmodule NextLS.ASTHelpers.Variables do &prewalk/2, &postwalk/2 ) + |> dbg(limit: :infinity) Enum.find_value(vars, fn %{name: name, sym_range: range, ref_range: ref_range} -> if position_in_range?(position, ref_range), do: {name, range}, else: nil @@ -29,6 +30,19 @@ defmodule NextLS.ASTHelpers.Variables do end end + def collect(ast) do + {_, %{cursor: cursor, symbols: symbols}} = + ast + |> Macro.traverse(%{vars: [], symbols: %{}, sym_ranges: [], scope: []}, &prewalk/2, &postwalk/2) + # |> dbg(limit: :infinity) + + cscope = Enum.reverse(cursor.scope) + + for {name, defs} <- symbols, def <- defs, List.starts_with?(cscope, Enum.reverse(def.scope)) do + to_string(name) + end + end + @spec list_variable_references(String.t(), {integer(), integer()}) :: [{atom(), {Range.t(), Range.t()}}] def list_variable_references(file, position) do file = File.read!(file) @@ -122,6 +136,18 @@ defmodule NextLS.ASTHelpers.Variables do {nil, acc} end + defp prewalk({:__cursor__, meta, _} = ast, acc) do + range = {meta[:line]..meta[:line], meta[:column]..meta[:column]} + + acc = + Map.put(acc, :cursor, %{ + range: range, + scope: acc.scope + }) + + {ast, acc} + end + # find variable defp prewalk({name, meta, nil} = ast, acc) do range = calculate_range(name, meta[:line], meta[:column]) @@ -133,7 +159,9 @@ defmodule NextLS.ASTHelpers.Variables do {ast, acc} end - defp prewalk(ast, acc), do: {ast, acc} + defp prewalk(ast, acc) do + {ast, acc} + end # decrease scope when exiting it defp postwalk({operation, _, _} = ast, acc) when operation in @scope_ends do diff --git a/lib/next_ls/runtime.ex b/lib/next_ls/runtime.ex index e7ec445e..6e0a28c1 100644 --- a/lib/next_ls/runtime.ex +++ b/lib/next_ls/runtime.ex @@ -33,10 +33,36 @@ defmodule NextLS.Runtime do end end + @spec compile(pid(), Keyword.t()) :: any() def compile(server, opts \\ []) do GenServer.call(server, {:compile, opts}, :infinity) end + defmacro execute(runtime, do: block) do + exprs = + case block do + {:__block__, _, exprs} -> exprs + expr -> [expr] + end + + for expr <- exprs, reduce: quote(do: :ok) do + ast -> + mfa = + case expr do + {{:., _, [mod, func]}, _, args} -> + [mod, func, args] + + {_func, _, _args} -> + raise "#{Macro.to_string(__MODULE__)}.execute/2 cannot be called with local functions" + end + + quote do + unquote(ast) + NextLS.Runtime.call(unquote(runtime), {unquote_splicing(mfa)}) + end + end + end + @impl GenServer def init(opts) do sname = "nextls-runtime-#{System.system_time()}" diff --git a/lib/next_ls/snippet.ex b/lib/next_ls/snippet.ex new file mode 100644 index 00000000..eb61feb5 --- /dev/null +++ b/lib/next_ls/snippet.ex @@ -0,0 +1,189 @@ +defmodule NextLS.Snippet do + @moduledoc false + + def get("defmodule" = label, nil) do + %GenLSP.Structures.CompletionItem{ + label: label, + kind: GenLSP.Enumerations.CompletionItemKind.snippet(), + insert_text_format: GenLSP.Enumerations.InsertTextFormat.snippet(), + insert_text: """ + defmodule ${1:ModuleName} do + $0 + end + """ + } + end + + def get("defstruct" = label, nil) do + %GenLSP.Structures.CompletionItem{ + label: label, + kind: GenLSP.Enumerations.CompletionItemKind.snippet(), + insert_text_format: GenLSP.Enumerations.InsertTextFormat.snippet(), + insert_text: """ + defstruct [${1:field}: ${2:default}] + """ + } + end + + def get("defprotocol" = label, nil) do + %GenLSP.Structures.CompletionItem{ + label: label, + kind: GenLSP.Enumerations.CompletionItemKind.snippet(), + insert_text_format: GenLSP.Enumerations.InsertTextFormat.snippet(), + insert_text: """ + defprotocol ${1:ProtocolName} do + def ${2:function_name}(${3:parameter_name}) + end + """ + } + end + + def get("defimpl" = label, nil) do + [ + %GenLSP.Structures.CompletionItem{ + label: label, + kind: GenLSP.Enumerations.CompletionItemKind.snippet(), + insert_text_format: GenLSP.Enumerations.InsertTextFormat.snippet(), + insert_text: """ + defimpl ${1:ProtocolName} do + def ${2:function_name}(${3:parameter_name}) do + $0 + end + end + """ + }, + %GenLSP.Structures.CompletionItem{ + label: label <> "f", + kind: GenLSP.Enumerations.CompletionItemKind.snippet(), + insert_text_format: GenLSP.Enumerations.InsertTextFormat.snippet(), + insert_text: """ + defimpl ${1:ProtocolName}, for: ${2:StructName} do + def ${3:function_name}(${4:parameter_name}) do + $0 + end + end + """ + } + ] + end + + def get("def" = label, nil) do + %GenLSP.Structures.CompletionItem{ + label: label, + kind: GenLSP.Enumerations.CompletionItemKind.snippet(), + insert_text_format: GenLSP.Enumerations.InsertTextFormat.snippet(), + insert_text: """ + def ${1:function_name}(${2:parameter_1}) do + $0 + end + """ + } + end + + def get("defp" = label, nil) do + %GenLSP.Structures.CompletionItem{ + label: label, + kind: GenLSP.Enumerations.CompletionItemKind.snippet(), + insert_text_format: GenLSP.Enumerations.InsertTextFormat.snippet(), + insert_text: """ + defp ${1:function_name}(${2:parameter_1}) do + $0 + end + """ + } + end + + def get("defmacro" = label, nil) do + %GenLSP.Structures.CompletionItem{ + label: label, + kind: GenLSP.Enumerations.CompletionItemKind.snippet(), + insert_text_format: GenLSP.Enumerations.InsertTextFormat.snippet(), + insert_text: """ + defmacro ${1:macro_name}(${2:parameter_1}) do + quote do + $0 + end + end + """ + } + end + + def get("defmacrop" = label, nil) do + %GenLSP.Structures.CompletionItem{ + label: label, + kind: GenLSP.Enumerations.CompletionItemKind.snippet(), + insert_text_format: GenLSP.Enumerations.InsertTextFormat.snippet(), + insert_text: """ + defmacrop ${1:macro_name}(${2:parameter_1}) do + quote do + $0 + end + end + """ + } + end + + def get("for" = label, nil) do + %GenLSP.Structures.CompletionItem{ + label: label, + kind: GenLSP.Enumerations.CompletionItemKind.snippet(), + insert_text_format: GenLSP.Enumerations.InsertTextFormat.snippet(), + insert_text: """ + for ${2:item} <- ${1:enumerable} do + $0 + end + """ + } + end + + def get("with" = label, nil) do + %GenLSP.Structures.CompletionItem{ + label: label, + kind: GenLSP.Enumerations.CompletionItemKind.snippet(), + insert_text_format: GenLSP.Enumerations.InsertTextFormat.snippet(), + insert_text: """ + with ${2:match} <- ${1:argument} do + $0 + end + """ + } + end + + def get("case" = label, nil) do + %GenLSP.Structures.CompletionItem{ + label: label, + kind: GenLSP.Enumerations.CompletionItemKind.snippet(), + insert_text_format: GenLSP.Enumerations.InsertTextFormat.snippet(), + insert_text: """ + case ${1:argument} do + ${2:match} -> + ${0::ok} + + _ -> + :error + end + """ + } + end + + def get("cond" = label, nil) do + %GenLSP.Structures.CompletionItem{ + label: label, + kind: GenLSP.Enumerations.CompletionItemKind.snippet(), + insert_text_format: GenLSP.Enumerations.InsertTextFormat.snippet(), + insert_text: """ + cond do + ${1:condition} -> + ${0::ok} + + true -> + ${2::error} + end + """ + } + end + + def get(_label, _trigger_character) do + nil + end +end diff --git a/test/next_ls/autocomplete_test.exs b/test/next_ls/autocomplete_test.exs new file mode 100644 index 00000000..f5eacc60 --- /dev/null +++ b/test/next_ls/autocomplete_test.exs @@ -0,0 +1,622 @@ +defmodule NextLS.AutocompleteTest do + use ExUnit.Case, async: true + + import NextLS.Support.Utils + + alias NextLS.Runtime + + @moduletag :tmp_dir + + setup %{tmp_dir: tmp_dir} do + File.write!(Path.join(tmp_dir, "mix.exs"), mix_exs()) + File.mkdir_p!(Path.join(tmp_dir, "lib")) + + File.write!(Path.join(tmp_dir, "lib/bar.ex"), """ + defmodule Bar do + defstruct [:foo] + + def foo(arg1) do + end + end + """) + + me = self() + + {:ok, logger} = + Task.start_link(fn -> + recv = fn recv -> + receive do + {:"$gen_cast", msg} -> send(me, msg) + end + + recv.(recv) + end + + recv.(recv) + end) + + {:ok, broker} = + Task.start_link(fn -> + recv = fn recv -> + receive do + msg -> + dbg(msg) + nil + end + + recv.(recv) + end + + recv.(recv) + end) + + on_init = fn msg -> send(me, msg) end + start_supervised!({Registry, keys: :duplicate, name: __MODULE__.Registry}) + tvisor = start_supervised!(Task.Supervisor) + + cwd = tmp_dir + + pid = + start_supervised!( + {Runtime, + name: "my_proj", + on_initialized: on_init, + task_supervisor: tvisor, + working_dir: cwd, + uri: "file://#{cwd}", + parent: self(), + logger: logger, + db: :some_db, + mix_env: "dev", + mix_target: "host", + registry: __MODULE__.Registry} + ) + + Process.link(pid) + + assert_receive :ready + + Process.put(:broker, broker) + + [runtime: pid, broker: broker] + end + + defp expand(runtime, expr) do + NextLS.Autocomplete.expand(Enum.reverse(expr), runtime) + end + + test "Erlang module completion", %{runtime: runtime} do + assert expand(runtime, ~c":zl") == {:yes, ~c"ib", []} + end + + test "Erlang module no completion", %{runtime: runtime} do + assert expand(runtime, ~c":unknown") == {:no, ~c"", []} + end + + test "Erlang module multiple values completion", %{runtime: runtime} do + {:yes, ~c"", list} = expand(runtime, ~c":logger") + assert ~c"logger" in list + assert ~c"logger_proxy" in list + end + + test "Erlang root completion", %{runtime: runtime} do + {:yes, ~c"", list} = expand(runtime, ~c":") + assert is_list(list) + assert ~c"lists" in list + assert ~c"Elixir.List" not in list + end + + test "Elixir proxy", %{runtime: runtime} do + {:yes, ~c"", list} = expand(runtime, ~c"E") + assert ~c"Elixir" in list + end + + test "Elixir completion", %{runtime: runtime} do + assert expand(runtime, ~c"En") == {:yes, ~c"um", []} + assert expand(runtime, ~c"Enumera") == {:yes, ~c"ble", []} + end + + test "Elixir type completion", %{runtime: runtime} do + assert expand(runtime, ~c"t :gen_ser") == {:yes, ~c"ver", []} + assert expand(runtime, ~c"t String") == {:yes, ~c"", [~c"String", ~c"StringIO"]} + + assert expand(runtime, ~c"t String.") == + {:yes, ~c"", [~c"codepoint/0", ~c"grapheme/0", ~c"pattern/0", ~c"t/0"]} + + assert expand(runtime, ~c"t String.grap") == {:yes, ~c"heme", []} + assert expand(runtime, ~c"t String.grap") == {:yes, ~c"heme", []} + assert {:yes, ~c"", [~c"date_time/0" | _]} = expand(runtime, ~c"t :file.") + assert expand(runtime, ~c"t :file.n") == {:yes, ~c"ame", []} + end + + test "Elixir callback completion", %{runtime: runtime} do + assert expand(runtime, ~c"b :strin") == {:yes, ~c"g", []} + assert expand(runtime, ~c"b String") == {:yes, ~c"", [~c"String", ~c"StringIO"]} + assert expand(runtime, ~c"b String.") == {:no, ~c"", []} + assert expand(runtime, ~c"b Access.") == {:yes, ~c"", [~c"fetch/2", ~c"get_and_update/3", ~c"pop/2"]} + assert expand(runtime, ~c"b GenServer.term") == {:yes, ~c"inate", []} + assert expand(runtime, ~c"b GenServer.term") == {:yes, ~c"inate", []} + assert expand(runtime, ~c"b :gen_server.handle_in") == {:yes, ~c"fo", []} + end + + test "Elixir helper completion with parentheses", %{runtime: runtime} do + assert expand(runtime, ~c"t(:gen_ser") == {:yes, ~c"ver", []} + assert expand(runtime, ~c"t(String") == {:yes, ~c"", [~c"String", ~c"StringIO"]} + + assert expand(runtime, ~c"t(String.") == + {:yes, ~c"", [~c"codepoint/0", ~c"grapheme/0", ~c"pattern/0", ~c"t/0"]} + + assert expand(runtime, ~c"t(String.grap") == {:yes, ~c"heme", []} + end + + test "Elixir completion with self", %{runtime: runtime} do + assert expand(runtime, ~c"Enumerable") == {:yes, ~c".", []} + end + + test "Elixir completion on modules from load path", %{runtime: runtime} do + assert expand(runtime, ~c"Str") == {:yes, [], [~c"Stream", ~c"String", ~c"StringIO"]} + assert expand(runtime, ~c"Ma") == {:yes, ~c"", [~c"Macro", ~c"Map", ~c"MapSet", ~c"MatchError"]} + assert expand(runtime, ~c"Dic") == {:yes, ~c"t", []} + # FIXME: ExUnit is not available when the MIX_ENV is dev. Need to figure out a way to make it complete later + # assert expand(runtime, ~c"Ex") == {:yes, [], [~c"ExUnit", ~c"Exception"]} + end + + @tag :pending + test "Elixir no completion for underscored functions with no doc", %{runtime: runtime} do + {:module, _, bytecode, _} = + defmodule Elixir.Sample do + @moduledoc false + def __foo__, do: 0 + @doc "Bar doc" + def __bar__, do: 1 + end + + File.write!("Elixir.Sample.beam", bytecode) + assert {:docs_v1, _, _, _, _, _, _} = Code.fetch_docs(Sample) + assert expand(runtime, ~c"Sample._") == {:yes, ~c"_bar__", []} + after + File.rm("Elixir.Sample.beam") + :code.purge(Sample) + :code.delete(Sample) + end + + test "Elixir no completion for default argument functions with doc set to false", %{runtime: runtime} do + {:yes, ~c"", available} = expand(runtime, ~c"String.") + refute Enum.member?(available, ~c"rjust/2") + assert Enum.member?(available, ~c"replace/3") + + assert expand(runtime, ~c"String.r") == {:yes, ~c"e", []} + + {:module, _, bytecode, _} = + defmodule Elixir.DefaultArgumentFunctions do + @moduledoc false + def foo(a \\ :a, b, c \\ :c), do: {a, b, c} + + def _do_fizz(a \\ :a, b, c \\ :c), do: {a, b, c} + + @doc false + def __fizz__(a \\ :a, b, c \\ :c), do: {a, b, c} + + @doc "bar/0 doc" + def bar, do: :bar + @doc false + def bar(a \\ :a, b, c \\ :c, d \\ :d), do: {a, b, c, d} + @doc false + def bar(a, b, c, d, e), do: {a, b, c, d, e} + + @doc false + def baz(a \\ :a), do: {a} + + @doc "biz/3 doc" + def biz(a, b, c \\ :c), do: {a, b, c} + end + + File.write!("Elixir.DefaultArgumentFunctions.beam", bytecode) + assert {:docs_v1, _, _, _, _, _, _} = Code.fetch_docs(DefaultArgumentFunctions) + + functions_list = [~c"bar/0", ~c"biz/2", ~c"biz/3", ~c"foo/1", ~c"foo/2", ~c"foo/3"] + assert expand(runtime, ~c"DefaultArgumentFunctions.") == {:yes, ~c"", functions_list} + + assert expand(runtime, ~c"DefaultArgumentFunctions.bi") == {:yes, ~c"z", []} + + assert expand(runtime, ~c"DefaultArgumentFunctions.foo") == + {:yes, ~c"", [~c"foo/1", ~c"foo/2", ~c"foo/3"]} + after + File.rm("Elixir.DefaultArgumentFunctions.beam") + :code.purge(DefaultArgumentFunctions) + :code.delete(DefaultArgumentFunctions) + end + + test "Elixir no completion", %{runtime: runtime} do + assert expand(runtime, ~c".") == {:no, ~c"", []} + assert expand(runtime, ~c"Xyz") == {:no, ~c"", []} + assert expand(runtime, ~c"x.Foo") == {:no, ~c"", []} + assert expand(runtime, ~c"x.Foo.get_by") == {:no, ~c"", []} + assert expand(runtime, ~c"@foo.bar") == {:no, ~c"", []} + end + + test "Elixir root submodule completion", %{runtime: runtime} do + assert expand(runtime, ~c"Elixir.Acce") == {:yes, ~c"ss", []} + end + + test "Elixir submodule completion", %{runtime: runtime} do + assert expand(runtime, ~c"String.Cha") == {:yes, ~c"rs", []} + end + + test "Elixir submodule no completion", %{runtime: runtime} do + assert expand(runtime, ~c"IEx.Xyz") == {:no, ~c"", []} + end + + test "function completion", %{runtime: runtime} do + assert expand(runtime, ~c"System.ve") == {:yes, ~c"rsion", []} + assert expand(runtime, ~c":ets.fun2") == {:yes, ~c"ms", []} + end + + test "function completion with arity", %{runtime: runtime} do + assert expand(runtime, ~c"String.printable?") == {:yes, ~c"", [~c"printable?/1", ~c"printable?/2"]} + assert expand(runtime, ~c"String.printable?/") == {:yes, ~c"", [~c"printable?/1", ~c"printable?/2"]} + + assert expand(runtime, ~c"Enum.count") == + {:yes, ~c"", [~c"count/1", ~c"count/2", ~c"count_until/2", ~c"count_until/3"]} + + assert expand(runtime, ~c"Enum.count/") == {:yes, ~c"", [~c"count/1", ~c"count/2"]} + end + + test "operator completion", %{runtime: runtime} do + assert expand(runtime, ~c"+") == {:yes, ~c"", [~c"+/1", ~c"+/2", ~c"++/2"]} + assert expand(runtime, ~c"+/") == {:yes, ~c"", [~c"+/1", ~c"+/2"]} + assert expand(runtime, ~c"++/") == {:yes, ~c"", [~c"++/2"]} + end + + test "sigil completion", %{runtime: runtime} do + {:yes, ~c"", sigils} = expand(runtime, ~c"~") + assert ~c"~C (sigil_C)" in sigils + {:yes, ~c"", sigils} = expand(runtime, ~c"~r") + assert ~c"\"" in sigils + assert ~c"(" in sigils + end + + @tag :pending + test "map atom key completion is supported", %{runtime: runtime} do + prev = "map = %{foo: 1, bar_1: 23, bar_2: 14}" + assert expand(runtime, ~c"#{prev}\nmap.f") == {:yes, ~c"oo", []} + # assert expand(runtime, ~c"map.b") == {:yes, ~c"ar_", []} + # assert expand(runtime, ~c"map.bar_") == {:yes, ~c"", [~c"bar_1", ~c"bar_2"]} + # assert expand(runtime, ~c"map.c") == {:no, ~c"", []} + # assert expand(runtime, ~c"map.") == {:yes, ~c"", [~c"bar_1", ~c"bar_2", ~c"foo"]} + # assert expand(runtime, ~c"map.foo") == {:no, ~c"", []} + end + + @tag :pending + test "nested map atom key completion is supported", %{runtime: runtime} do + prev = "map = %{nested: %{deeply: %{foo: 1, bar_1: 23, bar_2: 14, mod: String, num: 1}}}" + assert expand(runtime, ~c"map.nested.deeply.f") == {:yes, ~c"oo", []} + assert expand(runtime, ~c"map.nested.deeply.b") == {:yes, ~c"ar_", []} + assert expand(runtime, ~c"map.nested.deeply.bar_") == {:yes, ~c"", [~c"bar_1", ~c"bar_2"]} + + assert expand(runtime, ~c"map.nested.deeply.") == + {:yes, ~c"", [~c"bar_1", ~c"bar_2", ~c"foo", ~c"mod", ~c"num"]} + + assert expand(runtime, ~c"map.nested.deeply.mod.print") == {:yes, ~c"able?", []} + + assert expand(runtime, ~c"map.nested") == {:yes, ~c".", []} + assert expand(runtime, ~c"map.nested.deeply") == {:yes, ~c".", []} + assert expand(runtime, ~c"map.nested.deeply.foo") == {:no, ~c"", []} + + assert expand(runtime, ~c"map.nested.deeply.c") == {:no, ~c"", []} + assert expand(runtime, ~c"map.a.b.c.f") == {:no, ~c"", []} + end + + @tag :pending + test "map string key completion is not supported", %{runtime: runtime} do + prev = ~S(map = %{"foo" => 1}) + assert expand(runtime, ~c"map.f") == {:no, ~c"", []} + end + + @tag :pending + test "bound variables for modules and maps", %{runtime: runtime} do + prev = "num = 5; map = %{nested: %{num: 23}}" + assert expand(runtime, ~c"num.print") == {:no, ~c"", []} + assert expand(runtime, ~c"map.nested.num.f") == {:no, ~c"", []} + assert expand(runtime, ~c"map.nested.num.key.f") == {:no, ~c"", []} + end + + @tag :pending + test "access syntax is not supported", %{runtime: runtime} do + prev = "map = %{nested: %{deeply: %{num: 23}}}" + assert expand(runtime, ~c"map[:nested][:deeply].n") == {:no, ~c"", []} + assert expand(runtime, ~c"map[:nested].deeply.n") == {:no, ~c"", []} + assert expand(runtime, ~c"map.nested.[:deeply].n") == {:no, ~c"", []} + end + + @tag :pending + test "unbound variables is not supported", %{runtime: runtime} do + prev = "num = 5" + + assert expand(runtime, dbg(~c"#{prev}\nother_var.f")) == {:no, ~c"", []} + + # assert expand(runtime, ~c"a.b.c.d") == {:no, ~c"", []} + end + + test "macro completion", %{runtime: runtime} do + {:yes, ~c"", list} = expand(runtime, ~c"Kernel.is_") + assert is_list(list) + end + + test "imports completion", %{runtime: runtime} do + {:yes, ~c"", list} = expand(runtime, ~c"") + assert is_list(list) + assert ~c"h/1" in list + assert ~c"unquote/1" in list + assert ~c"pwd/0" in list + end + + test "kernel import completion", %{runtime: runtime} do + assert expand(runtime, ~c"defstru") == {:yes, ~c"ct", []} + assert expand(runtime, ~c"put_") == {:yes, ~c"", [~c"put_elem/3", ~c"put_in/2", ~c"put_in/3"]} + end + + test "variable name completion", %{runtime: runtime} do + prev = "numeral = 3; number = 3; nothing = nil" + assert expand(runtime, dbg(~c"#{prev}\nnumb")) == {:yes, ~c"er", []} + assert expand(runtime, ~c"#{prev}\nnum") == {:yes, ~c"", [~c"number", ~c"numeral"]} + # FIXME: variables + local functions + # assert expand(runtime, ~c"#{prev}\nno") == {:yes, ~c"", [~c"nothing", ~c"node/0", ~c"node/1", ~c"not/1"]} + end + + test "completion of manually imported functions and macros", %{runtime: runtime} do + prev = "import Enum\nimport Supervisor, only: [count_children: 1]\nimport Protocol" + + assert expand(runtime, ~c"#{prev}\nder") == {:yes, ~c"ive", []} + + assert expand(runtime, ~c"#{prev}\ntake") == + {:yes, ~c"", [~c"take/2", ~c"take_every/2", ~c"take_random/2", ~c"take_while/2"]} + + assert expand(runtime, ~c"#{prev}\ntake/") == {:yes, ~c"", [~c"take/2"]} + + assert expand(runtime, ~c"#{prev}\ncount") == + {:yes, ~c"", + [ + ~c"count/1", + ~c"count/2", + ~c"count_children/1", + ~c"count_until/2", + ~c"count_until/3" + ]} + + assert expand(runtime, ~c"#{prev}\ncount/") == {:yes, ~c"", [~c"count/1", ~c"count/2"]} + end + + defmacro define_var do + quote(do: var!(my_var_1, Elixir) = 1) + end + + test "ignores quoted variables when performing variable completion", %{runtime: runtime} do + prev = "require #{__MODULE__}; #{__MODULE__}.define_var(); my_var_2 = 2" + assert expand(runtime, ~c"my_var") == {:yes, ~c"_2", []} + end + + test "kernel special form completion", %{runtime: runtime} do + assert expand(runtime, ~c"unquote_spl") == {:yes, ~c"icing", []} + end + + test "completion inside expression", %{runtime: runtime} do + assert expand(runtime, ~c"1 En") == {:yes, ~c"um", []} + assert expand(runtime, ~c"Test(En") == {:yes, ~c"um", []} + assert expand(runtime, ~c"Test :zl") == {:yes, ~c"ib", []} + assert expand(runtime, ~c"[:zl") == {:yes, ~c"ib", []} + assert expand(runtime, ~c"{:zl") == {:yes, ~c"ib", []} + end + + defmodule SublevelTest.LevelA.LevelB do + @moduledoc false + end + + test "Elixir completion sublevel", %{runtime: runtime} do + assert expand(runtime, ~c"NextLS.AutocompleteTest.SublevelTest.") == {:yes, ~c"LevelA", []} + end + + test "complete aliases of Elixir modules", %{runtime: runtime} do + prev = "alias List, as: MyList" + assert expand(runtime, ~c"MyL") == {:yes, ~c"ist", []} + assert expand(runtime, ~c"MyList") == {:yes, ~c".", []} + assert expand(runtime, ~c"MyList.to_integer") == {:yes, [], [~c"to_integer/1", ~c"to_integer/2"]} + end + + test "complete aliases of Erlang modules", %{runtime: runtime} do + prev = "alias :lists, as: EList" + assert expand(runtime, ~c"#{prev}\nEL") == {:yes, ~c"ist", []} + assert expand(runtime, ~c"#{prev}\nEList") == {:yes, ~c".", []} + assert expand(runtime, ~c"#{prev}\nEList.map") == {:yes, [], [~c"map/2", ~c"mapfoldl/3", ~c"mapfoldr/3"]} + end + + test "completion for functions added when compiled module is reloaded", %{runtime: runtime} do + {:module, _, bytecode, _} = + defmodule Sample do + @moduledoc false + def foo, do: 0 + end + + File.write!("Elixir.NextLS.AutocompleteTest.Sample.beam", bytecode) + assert {:docs_v1, _, _, _, _, _, _} = Code.fetch_docs(Sample) + assert expand(runtime, ~c"NextLS.AutocompleteTest.Sample.foo") == {:yes, ~c"", [~c"foo/0"]} + + Code.compiler_options(ignore_module_conflict: true) + + defmodule Sample do + @moduledoc false + def foo, do: 0 + def foobar, do: 0 + end + + assert expand(runtime, ~c"NextLS.AutocompleteTest.Sample.foo") == {:yes, ~c"", [~c"foo/0", ~c"foobar/0"]} + after + File.rm("Elixir.NextLS.AutocompleteTest.Sample.beam") + Code.compiler_options(ignore_module_conflict: false) + :code.purge(Sample) + :code.delete(Sample) + end + + defmodule MyStruct do + @moduledoc false + defstruct [:my_val] + end + + test "completion for struct names", %{runtime: runtime} do + assert {:yes, ~c"", entries} = expand(runtime, ~c"%") + assert ~c"URI" in entries + assert ~c"IEx.History" in entries + assert ~c"IEx.Server" in entries + + assert {:yes, ~c"", entries} = expand(runtime, ~c"%IEx.") + assert ~c"IEx.History" in entries + assert ~c"IEx.Server" in entries + + assert expand(runtime, ~c"%IEx.AutocompleteTe") == {:yes, ~c"st.MyStruct{", []} + assert expand(runtime, ~c"%NextLS.AutocompleteTest.MyStr") == {:yes, ~c"uct{", []} + + prev = "alias NextLS.AutocompleteTest.MyStruct" + assert expand(runtime, ~c"%MyStr") == {:yes, ~c"uct{", []} + end + + test "completion for struct keys", %{runtime: runtime} do + assert {:yes, ~c"", entries} = expand(runtime, ~c"%URI{") + assert ~c"path:" in entries + assert ~c"query:" in entries + + assert {:yes, ~c"", entries} = expand(runtime, ~c"%URI{path: \"foo\",") + assert ~c"path:" not in entries + assert ~c"query:" in entries + + assert {:yes, ~c"ry: ", []} = expand(runtime, ~c"%URI{path: \"foo\", que") + assert {:no, [], []} = expand(runtime, ~c"%URI{path: \"foo\", unkno") + assert {:no, [], []} = expand(runtime, ~c"%Unkown{path: \"foo\", unkno") + end + + test "completion for struct keys in update syntax", %{runtime: runtime} do + assert {:yes, ~c"", entries} = expand(runtime, ~c"%URI{var | ") + assert ~c"path:" in entries + assert ~c"query:" in entries + + assert {:yes, ~c"", entries} = expand(runtime, ~c"%URI{var | path: \"foo\",") + assert ~c"path:" not in entries + assert ~c"query:" in entries + + assert {:yes, ~c"ry: ", []} = expand(runtime, ~c"%URI{var | path: \"foo\", que") + assert {:no, [], []} = expand(runtime, ~c"%URI{var | path: \"foo\", unkno") + assert {:no, [], []} = expand(runtime, ~c"%Unkown{var | path: \"foo\", unkno") + end + + test "completion for map keys in update syntax", %{runtime: runtime} do + prev = "map = %{some: 1, other: :ok, another: \"qwe\"}" + assert {:yes, ~c"", entries} = expand(runtime, ~c"%{map | ") + assert ~c"some:" in entries + assert ~c"other:" in entries + + assert {:yes, ~c"", entries} = expand(runtime, ~c"%{map | some: \"foo\",") + assert ~c"some:" not in entries + assert ~c"other:" in entries + + assert {:yes, ~c"er: ", []} = expand(runtime, ~c"%{map | some: \"foo\", oth") + assert {:no, [], []} = expand(runtime, ~c"%{map | some: \"foo\", unkno") + assert {:no, [], []} = expand(runtime, ~c"%{unknown | some: \"foo\", unkno") + end + + test "completion for struct var keys", %{runtime: runtime} do + prev = "struct = %NextLS.AutocompleteTest.MyStruct{}" + assert expand(runtime, ~c"struct.my") == {:yes, ~c"_val", []} + end + + test "completion for bitstring modifiers", %{runtime: runtime} do + assert {:yes, ~c"", entries} = expand(runtime, ~c"< + send(self(), expand(runtime, ~c"reduce(")) + end) == "\nreduce(enumerable, acc, fun)" + + assert_received {:yes, ~c"", [~c"reduce(enumerable, fun)"]} + + assert expand(runtime, ~c"take(") == {:yes, ~c"", [~c"take(enumerable, amount)"]} + assert expand(runtime, ~c"derive(") == {:yes, ~c"", [~c"derive(protocol, module, options \\\\ [])"]} + + defmodule NoDocs do + @moduledoc false + def sample(a), do: a + end + + assert {:yes, [], [_ | _]} = expand(runtime, ~c"NoDocs.sample(") + end + + test "path completion inside strings", %{tmp_dir: dir, runtime: runtime} do + dir |> Path.join("single1") |> File.touch() + dir |> Path.join("file1") |> File.touch() + dir |> Path.join("file2") |> File.touch() + dir |> Path.join("dir") |> File.mkdir() + dir |> Path.join("dir/file3") |> File.touch() + dir |> Path.join("dir/file4") |> File.touch() + + assert expand(runtime, ~c"\"./") == path_autocompletion(".") + assert expand(runtime, ~c"\"/") == path_autocompletion("/") + assert expand(runtime, ~c"\"./#\{") == expand(runtime, ~c"{") + assert expand(runtime, ~c"\"./#\{Str") == expand(runtime, ~c"{Str") + assert expand(runtime, ~c"Path.join(\"./\", is_") == expand(runtime, ~c"is_") + + assert expand(runtime, ~c"\"#{dir}/") == path_autocompletion(dir) + assert expand(runtime, ~c"\"#{dir}/sin") == {:yes, ~c"gle1", []} + assert expand(runtime, ~c"\"#{dir}/single1") == {:yes, ~c"\"", []} + assert expand(runtime, ~c"\"#{dir}/fi") == {:yes, ~c"le", []} + assert expand(runtime, ~c"\"#{dir}/file") == path_autocompletion(dir, "file") + assert expand(runtime, ~c"\"#{dir}/d") == {:yes, ~c"ir/", []} + assert expand(runtime, ~c"\"#{dir}/dir") == {:yes, ~c"/", []} + assert expand(runtime, ~c"\"#{dir}/dir/") == {:yes, ~c"file", []} + assert expand(runtime, ~c"\"#{dir}/dir/file") == dir |> Path.join("dir") |> path_autocompletion("file") + end + + defp path_autocompletion(dir, hint \\ "") do + dir + |> File.ls!() + |> Stream.filter(&String.starts_with?(&1, hint)) + |> Enum.map(&String.to_charlist/1) + |> case do + [] -> {:no, ~c"", []} + list -> {:yes, ~c"", list} + end + end +end diff --git a/test/next_ls/helpers/ast_helpers/variables_test.exs b/test/next_ls/helpers/ast_helpers/variables_test.exs index 63019566..98b010d7 100644 --- a/test/next_ls/helpers/ast_helpers/variables_test.exs +++ b/test/next_ls/helpers/ast_helpers/variables_test.exs @@ -186,7 +186,7 @@ defmodule NextLS.ASTHelpers.VariablesTest do assert {:charlie, {7..7, 25..31}} in refs end - test "symbol set in a match and corrctly processing ^", %{source: source} do + test "symbol set in a match and correctly processing ^", %{source: source} do refs = Variables.list_variable_references(source, {5, 5}) assert length(refs) == 2 assert {:charlie, {6..6, 17..23}} in refs