diff --git a/.formatter.exs b/.formatter.exs index 6ede60f6..ada3d7cd 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -12,10 +12,7 @@ plugins: [Styler], inputs: [ ".formatter.exs", - "{config,lib,}/**/*.{ex,exs}", - "test/next_ls_test.exs", - "test/test_helper.exs", - "test/next_ls/**/*.{ex,exs}", + "{config,lib,test}/**/*.{ex,exs}", "priv/**/*.ex" ] ] diff --git a/.mise.toml b/.mise.toml index ad993275..70087e12 100644 --- a/.mise.toml +++ b/.mise.toml @@ -3,5 +3,5 @@ KERL_BUILD_DOCS = "yes" [tools] erlang = "26.2.2" -elixir = "ref:514615d0347cb9bb513faa44ae1e36406979e516" +elixir = "ref:e3b6a91b173f7e836401a6a75c3906c26bd7fd39" zig = "0.11.0" diff --git a/.tool-versions b/.tool-versions index 1bbe2821..eb701dde 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,3 @@ erlang 26.2.2 -elixir ref:514615d0347cb9bb513faa44ae1e36406979e516 +elixir ref:e3b6a91b173f7e836401a6a75c3906c26bd7fd39 +zig 0.11.0 diff --git a/flake.nix b/flake.nix index 02f123fd..1855a862 100644 --- a/flake.nix +++ b/flake.nix @@ -26,9 +26,9 @@ musl = lib.optionals nixpkgs.legacyPackages.${system}.stdenv.isLinux (builtins.fetchurl (nixpkgs.lib.attrsets.getAttrs ["url" "sha256"] musls.${system})); otp = (pkgs.beam.packagesWith beamPackages.erlang).extend (final: prev: { elixir_1_17 = prev.elixir_1_16.override { - rev = "514615d0347cb9bb513faa44ae1e36406979e516"; + rev = "e3b6a91b173f7e836401a6a75c3906c26bd7fd39"; # You can discover this using Trust On First Use by filling in `lib.fakeHash` - sha256 = "sha256-lEnDgHi1sRg+3/JTnQJVo1qqSi0X2cNN4i9i9M95B2A="; + sha256 = "sha256-RK0aMW7pz7kQtK9XXN1wVCBxKOJKdQD7I/53V8rWD04="; version = "1.17.0-dev"; }; diff --git a/lib/next_ls.ex b/lib/next_ls.ex index bcb690c8..550e3bc2 100644 --- a/lib/next_ls.ex +++ b/lib/next_ls.ex @@ -54,6 +54,8 @@ defmodule NextLS do alias NextLS.Progress alias NextLS.Runtime + require NextLS.Runtime + def start_link(args) do {args, opts} = Keyword.split(args, [ @@ -63,7 +65,9 @@ defmodule NextLS do :runtime_task_supervisor, :dynamic_supervisor, :extensions, - :registry + :registry, + :bundle_base, + :mix_home ]) GenLSP.start_link(__MODULE__, args, opts) @@ -74,6 +78,8 @@ defmodule NextLS do task_supervisor = Keyword.fetch!(args, :task_supervisor) runtime_task_supervisor = Keyword.fetch!(args, :runtime_task_supervisor) dynamic_supervisor = Keyword.fetch!(args, :dynamic_supervisor) + bundle_base = Keyword.get(args, :bundle_base, Path.expand("~/.cache/elixir-tools/nextls")) + mixhome = Keyword.get(args, :mix_home, Path.expand("~/.mix")) registry = Keyword.fetch!(args, :registry) @@ -83,6 +89,8 @@ defmodule NextLS do cache = Keyword.fetch!(args, :cache) {:ok, logger} = DynamicSupervisor.start_child(dynamic_supervisor, {NextLS.Logger, lsp: lsp}) + NextLS.Runtime.BundledElixir.install(bundle_base, logger, mix_home: mixhome) + {:ok, assign(lsp, auto_update: Keyword.get(args, :auto_update, false), @@ -588,13 +596,16 @@ defmodule NextLS do end) |> Enum.join("\n") - env = + ast = spliced - |> Spitfire.parse(literal_encoder: &{:ok, {:__literal__, &2, [&1]}}) + |> Spitfire.parse(literal_encoder: &{:ok, {:__block__, &2, [&1]}}) |> then(fn {:ok, ast} -> ast {:error, ast, _} -> ast end) + + env = + ast |> NextLS.ASTHelpers.find_cursor() |> then(fn {:ok, cursor} -> @@ -627,6 +638,23 @@ defmodule NextLS do dispatch(lsp.assigns.registry, :runtimes, fn entries -> [{wuri, result}] = for {runtime, %{uri: wuri}} <- entries, String.starts_with?(uri, wuri) do + ast = + spliced + |> Spitfire.parse() + |> then(fn + {:ok, ast} -> ast + {:error, ast, _} -> ast + end) + + {:ok, {_, _, _, macro_env}} = Runtime.expand(runtime, ast, Path.basename(uri)) + + env = + env + |> Map.put(:functions, macro_env.functions) + |> Map.put(:macros, macro_env.macros) + |> Map.put(:aliases, macro_env.aliases) + |> Map.put(:attrs, macro_env.attrs) + {wuri, document_slice |> String.to_charlist() @@ -652,7 +680,7 @@ defmodule NextLS do {"#{name}/#{symbol.arity}", GenLSP.Enumerations.CompletionItemKind.function(), symbol.docs} :module -> - {name, GenLSP.Enumerations.CompletionItemKind.module(), ""} + {name, GenLSP.Enumerations.CompletionItemKind.module(), symbol.docs} :variable -> {name, GenLSP.Enumerations.CompletionItemKind.variable(), ""} @@ -666,6 +694,12 @@ defmodule NextLS do :keyword -> {name, GenLSP.Enumerations.CompletionItemKind.field(), ""} + :attribute -> + {name, GenLSP.Enumerations.CompletionItemKind.property(), ""} + + :sigil -> + {name, GenLSP.Enumerations.CompletionItemKind.function(), ""} + _ -> {name, GenLSP.Enumerations.CompletionItemKind.text(), ""} end @@ -838,6 +872,18 @@ defmodule NextLS do parent = self() + elixir_bin_path = + cond do + lsp.assigns.init_opts.elixir_bin_path != nil -> + lsp.assigns.init_opts.elixir_bin_path + + lsp.assigns.init_opts.experimental.completions.enable -> + NextLS.Runtime.BundledElixir.binpath() + + true -> + "elixir" |> System.find_executable() |> Path.dirname() + end + for %{uri: uri, name: name} <- lsp.assigns.workspace_folders do token = Progress.token() Progress.start(lsp, token, "Initializing NextLS runtime for folder #{name}...") @@ -859,6 +905,7 @@ defmodule NextLS do uri: uri, mix_env: lsp.assigns.init_opts.mix_env, mix_target: lsp.assigns.init_opts.mix_target, + elixir_bin_path: elixir_bin_path, on_initialized: fn status -> if status == :ready do Progress.stop(lsp, token, "NextLS runtime for folder #{name} has initialized!") @@ -870,7 +917,7 @@ defmodule NextLS do for {pid, _} <- entries, do: send(pid, msg) end) - send(parent, msg) + Process.send(parent, msg, []) else Progress.stop(lsp, token) @@ -884,7 +931,7 @@ defmodule NextLS do ) end - {:noreply, lsp} + {:noreply, assign(lsp, elixir_bin_path: elixir_bin_path)} end def handle_notification(%TextDocumentDidSave{}, %{assigns: %{ready: false}} = lsp) do @@ -956,7 +1003,7 @@ defmodule NextLS do }, lsp ) do - dispatch(lsp.assigns.registry, :runtime_supervisors, fn entries -> + NextLS.Registry.dispatch(lsp.assigns.registry, :runtime_supervisors, fn entries -> names = Enum.map(entries, fn {_, %{name: name}} -> name end) for %{name: name, uri: uri} <- added, name not in names do @@ -976,6 +1023,7 @@ defmodule NextLS do runtime: [ task_supervisor: lsp.assigns.runtime_task_supervisor, working_dir: working_dir, + elixir_bin_path: lsp.assigns.elixir_bin_path, uri: uri, mix_env: lsp.assigns.init_opts.mix_env, mix_target: lsp.assigns.init_opts.mix_target, @@ -1019,47 +1067,51 @@ defmodule NextLS do lsp = for %{type: type, uri: uri} <- changes, reduce: lsp do lsp -> + file = URI.parse(uri).path + cond do type == GenLSP.Enumerations.FileChangeType.created() -> - with {:ok, text} <- File.read(URI.parse(uri).path) do + with {:ok, text} <- File.read(file) do put_in(lsp.assigns.documents[uri], String.split(text, "\n")) else _ -> lsp end type == GenLSP.Enumerations.FileChangeType.changed() -> - with {:ok, text} <- File.read(URI.parse(uri).path) do + with {:ok, text} <- File.read(file) do put_in(lsp.assigns.documents[uri], String.split(text, "\n")) else _ -> lsp end type == GenLSP.Enumerations.FileChangeType.deleted() -> - dispatch(lsp.assigns.registry, :databases, fn entries -> - for {pid, _} <- entries do - file = URI.parse(uri).path - - NextLS.DB.query( - pid, - ~Q""" - DELETE FROM symbols - WHERE symbols.file = ?; - """, - [file] - ) - - NextLS.DB.query( - pid, - ~Q""" - DELETE FROM 'references' AS refs - WHERE refs.file = ?; - """, - [file] - ) - end - end) + if not File.exists?(file) do + dispatch(lsp.assigns.registry, :databases, fn entries -> + for {pid, _} <- entries do + NextLS.DB.query( + pid, + ~Q""" + DELETE FROM symbols + WHERE symbols.file = ?; + """, + [file] + ) + + NextLS.DB.query( + pid, + ~Q""" + DELETE FROM 'references' AS refs + WHERE refs.file = ?; + """, + [file] + ) + end + end) - update_in(lsp.assigns.documents, &Map.drop(&1, [uri])) + update_in(lsp.assigns.documents, &Map.drop(&1, [uri])) + else + lsp + end end end @@ -1136,25 +1188,28 @@ defmodule NextLS do end def handle_info({:runtime_ready, name, runtime_pid}, lsp) do - token = Progress.token() - Progress.start(lsp, token, "Compiling #{name}...") + case NextLS.Registry.dispatch(lsp.assigns.registry, :databases, fn entries -> + Enum.find(entries, fn {_, %{runtime: runtime}} -> runtime == name end) + end) do + {_, %{mode: mode}} -> + token = Progress.token() + Progress.start(lsp, token, "Compiling #{name}...") - {_, %{mode: mode}} = - dispatch(lsp.assigns.registry, :databases, fn entries -> - Enum.find(entries, fn {_, %{runtime: runtime}} -> runtime == name end) - end) + ref = make_ref() + Runtime.compile(runtime_pid, caller_ref: ref, force: mode == :reindex) - ref = make_ref() - Runtime.compile(runtime_pid, caller_ref: ref, force: mode == :reindex) + refresh_refs = Map.put(lsp.assigns.refresh_refs, ref, {token, "Compiled #{name}!"}) - refresh_refs = Map.put(lsp.assigns.refresh_refs, ref, {token, "Compiled #{name}!"}) + {:noreply, assign(lsp, ready: true, refresh_refs: refresh_refs)} - {:noreply, assign(lsp, ready: true, refresh_refs: refresh_refs)} + nil -> + {:noreply, assign(lsp, ready: true)} + end end def handle_info({:runtime_failed, name, status}, lsp) do {pid, %{init_arg: init_arg}} = - dispatch(lsp.assigns.registry, :runtime_supervisors, fn entries -> + NextLS.Registry.dispatch(lsp.assigns.registry, :runtime_supervisors, fn entries -> Enum.find(entries, fn {_pid, %{name: n}} -> n == name end) end) @@ -1186,6 +1241,7 @@ defmodule NextLS do ) File.rm_rf!(Path.join(init_arg[:runtime][:working_dir], ".elixir-tools/_build")) + File.rm_rf!(Path.join(init_arg[:runtime][:working_dir], ".elixir-tools/_build2")) case System.cmd("mix", ["deps.get"], env: [{"MIX_ENV", "dev"}, {"MIX_BUILD_ROOT", ".elixir-tools/_build"}], @@ -1267,6 +1323,9 @@ defmodule NextLS do receive do {^ref, result} -> result + after + 1000 -> + :timeout end end @@ -1441,6 +1500,7 @@ defmodule NextLS do defstruct mix_target: "host", mix_env: "dev", + elixir_bin_path: nil, experimental: %NextLS.InitOpts.Experimental{}, extensions: %NextLS.InitOpts.Extensions{} @@ -1450,6 +1510,8 @@ defmodule NextLS do schema(__MODULE__, %{ optional(:mix_target) => str(), optional(:mix_env) => str(), + optional(:mix_env) => str(), + optional(:elixir_bin_path) => str(), optional(:experimental) => schema(NextLS.InitOpts.Experimental, %{ optional(:completions) => diff --git a/lib/next_ls/autocomplete.ex b/lib/next_ls/autocomplete.ex index 040a5985..742ded29 100644 --- a/lib/next_ls/autocomplete.ex +++ b/lib/next_ls/autocomplete.ex @@ -27,7 +27,7 @@ defmodule NextLS.Autocomplete do def expand(code, runtime, env) do case path_fragment(code) do [] -> expand_code(code, runtime, env) - path -> expand_path(path) + path -> expand_path(path, runtime) end end @@ -37,7 +37,10 @@ defmodule NextLS.Autocomplete do case Code.Fragment.cursor_context(code) do {:alias, alias} -> - expand_aliases(List.to_string(alias), runtime) + expand_aliases(List.to_string(alias), runtime, env) + + {:module_attribute, hint} -> + match_attributes(List.to_string(hint), env) {:unquoted_atom, unquoted_atom} -> expand_erlang_modules(List.to_string(unquoted_atom), runtime) @@ -49,66 +52,64 @@ defmodule NextLS.Autocomplete do # expand_typespecs(expansion, runtime, &get_module_types(&1, runtime)) {:dot, path, hint} -> - if alias = alias_only(path, hint, code, runtime) do - expand_aliases(List.to_string(alias), runtime) + if alias = alias_only(path, hint, code, runtime, env) do + expand_aliases(List.to_string(alias), runtime, env) else - expand_dot(path, List.to_string(hint), false, runtime) + expand_dot(path, List.to_string(hint), false, runtime, env) end {:dot_arity, path, hint} -> - expand_dot(path, List.to_string(hint), true, runtime) + expand_dot(path, List.to_string(hint), true, runtime, env) {:dot_call, path, hint} -> - expand_dot_call(path, List.to_atom(hint), runtime) + expand_dot_call(path, List.to_atom(hint), runtime, env) :expr -> - expand_container_context(code, :expr, "", runtime) || expand_local_or_var(code, "", runtime, env) + expand_container_context(code, :expr, "", runtime, env) || expand_local_or_var(code, "", runtime, env) {:local_or_var, local_or_var} -> hint = List.to_string(local_or_var) - expand_container_context(code, :expr, hint, runtime) || + expand_container_context(code, :expr, hint, runtime, env) || expand_local_or_var(hint, List.to_string(local_or_var), runtime, env) {:local_arity, local} -> - expand_local(List.to_string(local), true, runtime) + expand_local(List.to_string(local), true, runtime, env) {:local_call, local} when local in @alias_only_charlists -> - expand_aliases("", runtime) + expand_aliases("", runtime, env) {:local_call, local} -> expand_local_call(List.to_atom(local), runtime, env) {:operator, operator} when operator in ~w(:: -)c -> - expand_container_context(code, :operator, "", runtime) || - expand_local(List.to_string(operator), false, runtime) + expand_container_context(code, :operator, "", runtime, env) || + expand_local(List.to_string(operator), false, runtime, env) {:operator, operator} -> - expand_local(List.to_string(operator), false, runtime) + expand_local(List.to_string(operator), false, runtime, env) {:operator_arity, operator} -> - expand_local(List.to_string(operator), true, runtime) + expand_local(List.to_string(operator), true, runtime, env) {:operator_call, operator} when operator in ~w(|)c -> - expand_container_context(code, :expr, "", runtime) || expand_local_or_var("", "", runtime, env) + expand_container_context(code, :expr, "", runtime, env) || expand_local_or_var("", "", runtime, env) {:operator_call, _operator} -> expand_local_or_var("", "", runtime, env) {:sigil, []} -> - expand_sigil(runtime) + expand_sigil(runtime, env) {:sigil, [_]} -> {:yes, [], ~w|" """ ' ''' \( / < [ { \||c} {:struct, struct} when is_list(struct) -> - expand_structs(List.to_string(struct), runtime) + expand_structs(List.to_string(struct), runtime, env) {:struct, {:dot, {:alias, struct}, ~c""}} when is_list(struct) -> - expand_structs(List.to_string(struct ++ ~c"."), runtime) + expand_structs(List.to_string(struct ++ ~c"."), runtime, env) - # {:module_attribute, charlist} - # :none _ -> no() end @@ -164,15 +165,15 @@ defmodule NextLS.Autocomplete do ## Expand call defp expand_local_call(fun, runtime, env) do - runtime + env |> 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(runtime, env) end - defp expand_dot_call(path, fun, runtime) do - case expand_dot_path(path, runtime) do + defp expand_dot_call(path, fun, runtime, env) do + case expand_dot_path(path, env) do {:ok, mod} when is_atom(mod) -> fun |> get_signatures(mod) |> expand_signatures(runtime) _ -> no() end @@ -187,8 +188,8 @@ defmodule NextLS.Autocomplete do end defp expand_signatures([_ | _] = signatures, _runtime) do - [head | tail] = Enum.sort(signatures, &(String.length(&1) <= String.length(&2))) - if tail != [], do: IO.write("\n" <> (tail |> Enum.reverse() |> Enum.join("\n"))) + [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 @@ -196,8 +197,8 @@ defmodule NextLS.Autocomplete do ## Expand dot - defp expand_dot(path, hint, exact?, runtime) do - case expand_dot_path(path, runtime) do + defp expand_dot(path, hint, exact?, runtime, env) do + case expand_dot_path(path, env) do {:ok, mod} when is_atom(mod) and hint == "" -> expand_dot_aliases(mod, runtime) {:ok, mod} when is_atom(mod) -> expand_require(mod, hint, exact?, runtime) {:ok, map} when is_map(map) -> expand_map_field_access(map, hint) @@ -205,33 +206,33 @@ defmodule NextLS.Autocomplete do end end - defp expand_dot_path({:unquoted_atom, var}, _runtime) do + defp expand_dot_path({:unquoted_atom, var}, _env) do {:ok, List.to_atom(var)} end - defp expand_dot_path(path, runtime) do - case recur_expand_dot_path(path, runtime) do - {:ok, [_ | _] = path} -> value_from_binding(Enum.reverse(path), runtime) + defp expand_dot_path(path, env) do + case recur_expand_dot_path(path, env) do + {:ok, [_ | _] = path} -> value_from_binding(Enum.reverse(path), env) other -> other end end - defp recur_expand_dot_path({:var, var}, _runtime) do + defp recur_expand_dot_path({:var, var}, _env) do {:ok, [List.to_atom(var)]} end - defp recur_expand_dot_path({:alias, var}, runtime) do - {:ok, var |> List.to_string() |> String.split(".") |> value_from_alias(runtime)} + defp recur_expand_dot_path({:alias, var}, env) do + {:ok, var |> List.to_string() |> String.split(".") |> value_from_alias(env)} end - defp recur_expand_dot_path({:dot, parent, call}, runtime) do - case recur_expand_dot_path(parent, runtime) do + defp recur_expand_dot_path({:dot, parent, call}, env) do + case recur_expand_dot_path(parent, env) do {:ok, [_ | _] = path} -> {:ok, [List.to_atom(call) | path]} _ -> :error end end - defp recur_expand_dot_path(_, _runtime) do + defp recur_expand_dot_path(_, _env) do :error end @@ -260,30 +261,31 @@ defmodule NextLS.Autocomplete do ## Expand local or var defp expand_local_or_var(code, hint, runtime, env) do - format_expansion(match_var(code, hint, runtime, env) ++ match_local(hint, false, runtime)) + format_expansion(match_var(code, hint, runtime, env) ++ match_local(hint, false, runtime, env)) end - defp expand_local(hint, exact?, runtime) do - format_expansion(match_local(hint, exact?, runtime)) + defp expand_local(hint, exact?, runtime, env) do + format_expansion(match_local(hint, exact?, runtime, env)) end - defp expand_sigil(runtime) do + defp expand_sigil(runtime, env) do sigils = "sigil_" - |> match_local(false, runtime) + |> match_local(false, runtime, env) |> Enum.map(fn %{name: "sigil_" <> rest} -> %{kind: :sigil, name: rest} end) - format_expansion(match_local("~", false, runtime) ++ sigils) + format_expansion(match_local("~", false, runtime, env) ++ sigils) end - defp match_local(hint, exact?, runtime) do - imports = runtime |> imports_from_env() |> Enum.flat_map(&elem(&1, 1)) + defp match_local(hint, exact?, runtime, env) do special_form_funs = get_module_funs(Kernel.SpecialForms, runtime) - kernel_funs = get_module_funs(Kernel, runtime) + # kernel_funs = get_module_funs(Kernel, runtime) + # match_module_funs(runtime, Kernel, kernel_funs, hint, exact?) ++ match_module_funs(runtime, Kernel.SpecialForms, special_form_funs, hint, exact?) ++ - match_module_funs(runtime, Kernel, kernel_funs, hint, exact?) ++ - match_module_funs(runtime, nil, imports, hint, exact?) + Enum.flat_map(imports_from_env(env), fn {mod, funs} -> + match_module_funs(runtime, mod, funs, hint, exact?) + end) end defp match_var(code, hint, _runtime, env) do @@ -302,15 +304,32 @@ defmodule NextLS.Autocomplete do defp match_erlang_modules(hint, runtime) do for mod <- match_modules(hint, false, runtime), usable_as_unquoted_module?(mod) do - %{kind: :module, name: mod} + {content_type, mdoc} = + case NextLS.Runtime.execute(runtime, do: Code.fetch_docs(mod)) do + {:ok, {:docs_v1, _, _lang, content_type, %{"en" => mdoc}, _, _fdocs}} -> + {content_type, mdoc} + + _ -> + {"text/markdown", nil} + end + + %{ + kind: :module, + name: mod, + docs: """ + ## #{Macro.to_string(mod)} + + #{NextLS.HoverHelpers.to_markdown(content_type, mdoc)} + """ + } end end ## Structs - defp expand_structs(hint, runtime) do + defp expand_structs(hint, runtime, env) do aliases = - for {alias, mod} <- aliases_from_env(runtime), + for {alias, mod} <- aliases_from_env(env), [name] = Module.split(alias), String.starts_with?(name, hint), do: {mod, name} @@ -338,9 +357,9 @@ defmodule NextLS.Autocomplete do format_expansion(refs) end - @dialyzer {:nowarn_function, expand_container_context: 4} - defp expand_container_context(code, context, hint, runtime) do - case container_context(code, runtime) do + @dialyzer {:nowarn_function, expand_container_context: 5} + defp expand_container_context(code, context, hint, runtime, env) do + case container_context(code, runtime, env) do {:map, map, pairs} when context == :expr -> container_context_map_fields(pairs, map, hint) @@ -383,12 +402,12 @@ defmodule NextLS.Autocomplete do format_expansion(entries) end - defp container_context(code, runtime) do + defp container_context(code, runtime, env) 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, runtime) + container_context_struct(cursor, pairs, aliases, runtime, env) [ cursor, @@ -397,7 +416,7 @@ defmodule NextLS.Autocomplete do {:%{}, _, _}, {:%, _, [{:__aliases__, _, aliases}, _map]} | _ ] -> - container_context_struct(cursor, pairs, aliases, runtime) + container_context_struct(cursor, pairs, aliases, runtime, env) [cursor, pairs, {:|, _, [{variable, _, nil} | _]}, {:%{}, _, _} | _] -> container_context_map(cursor, pairs, variable, runtime) @@ -425,9 +444,9 @@ defmodule NextLS.Autocomplete do defp remove_operators(tail, _previous), do: tail - defp container_context_struct(cursor, pairs, aliases, runtime) do + defp container_context_struct(cursor, pairs, aliases, runtime, env) do with {pairs, [^cursor]} <- Enum.split(pairs, -1), - alias = value_from_alias(aliases, runtime), + alias = value_from_alias(aliases, env), true <- Keyword.keyword?(pairs) and ensure_loaded?(alias, runtime) and NextLS.Runtime.execute!(runtime, do: Kernel.function_exported?(alias, :__struct__, 1)) do @@ -450,20 +469,20 @@ defmodule NextLS.Autocomplete do ## Aliases and modules - defp alias_only(path, hint, code, runtime) do + defp alias_only(path, hint, code, runtime, env) do with {:alias, alias} <- path, [] <- hint, - :alias_only <- container_context(code, runtime) do + :alias_only <- container_context(code, runtime, env) do alias ++ [?.] else _ -> nil end end - defp expand_aliases(all, runtime) do + defp expand_aliases(all, runtime, env) do case String.split(all, ".") do [hint] -> - all = match_aliases(hint, runtime) ++ match_elixir_modules(Elixir, hint, runtime) + all = match_aliases(hint, runtime, env) ++ match_elixir_modules(Elixir, hint, runtime) format_expansion(all) parts -> @@ -471,25 +490,51 @@ defmodule NextLS.Autocomplete do list = Enum.take(parts, length(parts) - 1) list - |> value_from_alias(runtime) + |> value_from_alias(env) |> match_elixir_modules(hint, runtime) |> format_expansion() end end - defp value_from_alias([name | rest], runtime) do - case Keyword.fetch(aliases_from_env(runtime), Module.concat(Elixir, name)) do + defp match_attributes(hint, env) do + format_expansion( + for attr <- env.attrs, String.starts_with?(attr, hint) do + %{kind: :attribute, name: attr, docs: ""} + end + ) + end + + defp value_from_alias([name | rest], env) do + case Keyword.fetch(aliases_from_env(env), 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, runtime) do - for {alias, module} <- aliases_from_env(runtime), + defp match_aliases(hint, runtime, env) do + for {alias, module} <- aliases_from_env(env), [name] = Module.split(alias), String.starts_with?(name, hint) do - %{kind: :module, name: name, module: module} + {content_type, mdoc} = + case NextLS.Runtime.execute(runtime, do: Code.fetch_docs(module)) do + {:ok, {:docs_v1, _, _lang, content_type, %{"en" => mdoc}, _, _fdocs}} -> + {content_type, mdoc} + + _ -> + {"text/markdown", nil} + end + + %{ + kind: :module, + name: name, + module: module, + docs: """ + ## #{Macro.to_string(module)} + + #{NextLS.HoverHelpers.to_markdown(content_type, mdoc)} + """ + } end end @@ -498,13 +543,35 @@ defmodule NextLS.Autocomplete do depth = length(String.split(name, ".")) + 1 base = name <> "." <> hint - for mod <- match_modules(base, module == Elixir, runtime), - parts = String.split(mod, "."), - depth <= length(parts), - name = Enum.at(parts, depth - 1), - valid_alias_piece?("." <> name), - uniq: true, - do: %{kind: :module, name: name} + for_result = + for mod <- match_modules(base, module == Elixir, runtime), + parts = String.split(mod, "."), + depth <= length(parts), + name = Enum.at(parts, depth - 1), + valid_alias_piece?("." <> name) do + alias = Module.concat([mod]) + + {content_type, mdoc} = + case NextLS.Runtime.execute(runtime, do: Code.fetch_docs(alias)) do + {:ok, {:docs_v1, _, _lang, content_type, %{"en" => mdoc}, _, _fdocs}} -> + {content_type, mdoc} + + _ -> + {"text/markdown", nil} + end + + %{ + kind: :module, + name: name, + docs: """ + ## #{Macro.to_string(alias)} + + #{NextLS.HoverHelpers.to_markdown(content_type, mdoc)} + """ + } + end + + Enum.uniq_by(for_result, & &1.name) end defp valid_alias_piece?(<>) when char in ?A..?Z, do: valid_alias_rest?(rest) @@ -555,7 +622,7 @@ defmodule NextLS.Autocomplete do elixir_root? |> get_modules(runtime) |> Enum.reject(fn mod -> - Enum.any?(["Elixir.NextLSPrivate", "_next_ls_private_compiler"], fn prefix -> + Enum.any?(["Elixir.NextLSPrivate", "_next_ls_private"], fn prefix -> String.starts_with?(mod, prefix) end) end) @@ -753,25 +820,12 @@ defmodule NextLS.Autocomplete do ## Evaluator interface - defp imports_from_env(_runtime) do - # with {evaluator, server} <- IEx.Broker.evaluator(runtime), - # env_fields = IEx.Evaluator.fields_from_env(evaluator, server, [:functions, :macros]), - # %{functions: funs, macros: macros} <- env_fields do - # funs ++ macros - # else - # _ -> [] - # end - [] + defp imports_from_env(env) do + env.functions ++ env.macros end - defp aliases_from_env(_runtime) do - # with {evaluator, server} <- IEx.Broker.evaluator(runtime), - # %{aliases: aliases} <- IEx.Evaluator.fields_from_env(evaluator, server, [:aliases]) do - # aliases - # else - # _ -> [] - # end - [] + defp aliases_from_env(env) do + env.aliases end defp variables_from_binding(_hint, env) do @@ -801,10 +855,10 @@ defmodule NextLS.Autocomplete do defp path_fragment([?" | _], _acc), do: [] defp path_fragment([h | t], acc), do: path_fragment(t, [h | acc]) - defp expand_path(path) do + defp expand_path(path, runtime) do path |> List.to_string() - |> ls_prefix() + |> ls_prefix(runtime) |> Enum.map(fn path -> kind = if(File.dir?(path), do: :dir, else: :file) name = Path.basename(path) @@ -818,11 +872,11 @@ defmodule NextLS.Autocomplete do defp prefix_from_dir(".", <>) when c != ?., do: "" defp prefix_from_dir(dir, _fragment), do: dir - defp ls_prefix(path) do + defp ls_prefix(path, runtime) do dir = Path.dirname(path) prefix = prefix_from_dir(dir, path) - case File.ls(dir) do + case NextLS.Runtime.execute!(runtime, do: File.ls(dir)) do {:ok, list} -> list |> Enum.map(&Path.join(prefix, &1)) diff --git a/lib/next_ls/helpers/ast_helpers.ex b/lib/next_ls/helpers/ast_helpers.ex index 1277c6b5..b0364c6a 100644 --- a/lib/next_ls/helpers/ast_helpers.ex +++ b/lib/next_ls/helpers/ast_helpers.ex @@ -159,7 +159,9 @@ defmodule NextLS.ASTHelpers do ast |> Zipper.zip() |> Zipper.find(fn + {:@, _, [{:__cursor__, _, []}]} -> true {:__cursor__, _, []} -> true + {{:., _, [_, :__cursor__]}, _, _} -> true _ -> false end) do {:error, :not_found} diff --git a/lib/next_ls/helpers/ast_helpers/env.ex b/lib/next_ls/helpers/ast_helpers/env.ex index c816afe1..f200d2be 100644 --- a/lib/next_ls/helpers/ast_helpers/env.ex +++ b/lib/next_ls/helpers/ast_helpers/env.ex @@ -15,7 +15,7 @@ defmodule NextLS.ASTHelpers.Env do zipper = Zipper.prev(cursor) env = - ascend(zipper, %{variables: []}, fn node, zipper, acc -> + ascend(zipper, %{variables: [], attrs: []}, fn node, zipper, acc -> is_inside = with {_, _, _} <- node, range when not is_nil(range) <- Sourceror.get_range(node) do diff --git a/lib/next_ls/progress.ex b/lib/next_ls/progress.ex index 4e26e305..a7485635 100644 --- a/lib/next_ls/progress.ex +++ b/lib/next_ls/progress.ex @@ -17,10 +17,7 @@ defmodule NextLS.Progress do GenLSP.notify(lsp, %GenLSP.Notifications.DollarProgress{ params: %GenLSP.Structures.ProgressParams{ token: token, - value: %GenLSP.Structures.WorkDoneProgressBegin{ - kind: "begin", - title: msg - } + value: %GenLSP.Structures.WorkDoneProgressBegin{kind: "begin", title: msg} } }) end) diff --git a/lib/next_ls/registry.ex b/lib/next_ls/registry.ex new file mode 100644 index 00000000..a04073b0 --- /dev/null +++ b/lib/next_ls/registry.ex @@ -0,0 +1,43 @@ +defmodule NextLS.Registry do + @moduledoc """ + This module includes a version of the `Registry.dispatch/4` function included with the standard library that + does a few of things differently. + + 1. It will execute the callback even if the registry contains no processes for the given key. + 2. The function only works with duplicate registries with a single partition. + 3. The value returned by the callback is returned by the function. + """ + @key_info -2 + + def dispatch(registry, key, mfa_or_fun, _opts \\ []) + when is_atom(registry) and is_function(mfa_or_fun, 1) + when is_atom(registry) and tuple_size(mfa_or_fun) == 3 do + case key_info!(registry) do + {:duplicate, 1, key_ets} -> + key_ets + |> safe_lookup_second(key) + |> apply_non_empty_to_mfa_or_fun(mfa_or_fun) + end + end + + defp apply_non_empty_to_mfa_or_fun(entries, {module, function, args}) do + apply(module, function, [entries | args]) + end + + defp apply_non_empty_to_mfa_or_fun(entries, fun) do + fun.(entries) + end + + defp safe_lookup_second(ets, key) do + :ets.lookup_element(ets, key, 2) + catch + :error, :badarg -> [] + end + + defp key_info!(registry) do + :ets.lookup_element(registry, @key_info, 2) + catch + :error, :badarg -> + raise ArgumentError, "unknown registry: #{inspect(registry)}" + end +end diff --git a/lib/next_ls/runtime.ex b/lib/next_ls/runtime.ex index 8877bfbb..b969106d 100644 --- a/lib/next_ls/runtime.ex +++ b/lib/next_ls/runtime.ex @@ -21,6 +21,11 @@ defmodule NextLS.Runtime do GenServer.call(server, {:call, mfa, ctx}, :infinity) end + @spec expand(pid(), Macro.t(), String.t()) :: any() + def expand(server, ast, file) do + GenServer.call(server, {:expand, ast, file}, :infinity) + end + @spec ready?(pid()) :: boolean() def ready?(server), do: GenServer.call(server, :ready?) @@ -32,11 +37,16 @@ defmodule NextLS.Runtime do end def await(server, count) do - if ready?(server) do + with {:alive, true} <- {:alive, Process.alive?(server)}, + true <- ready?(server) do :ok else - Process.sleep(500) - await(server, count - 1) + {:alive, false} -> + :timeout + + _ -> + Process.sleep(500) + await(server, count - 1) end end @@ -100,6 +110,9 @@ defmodule NextLS.Runtime do db = Keyword.fetch!(opts, :db) mix_env = Keyword.fetch!(opts, :mix_env) mix_target = Keyword.fetch!(opts, :mix_target) + elixir_bin_path = Keyword.get(opts, :elixir_bin_path) + + elixir_exe = Path.join(elixir_bin_path, "elixir") Registry.register(registry, :runtimes, %{name: name, uri: uri, path: working_dir, db: db}) @@ -119,8 +132,7 @@ defmodule NextLS.Runtime do path = System.get_env("PATH") new_path = String.replace(path, bindir <> ":", "") - with dir when is_list(dir) <- :code.priv_dir(:next_ls), - elixir_exe when is_binary(elixir_exe) <- System.find_executable("elixir") do + with dir when is_list(dir) <- :code.priv_dir(:next_ls) do exe = dir |> Path.join("cmd") @@ -248,10 +260,7 @@ defmodule NextLS.Runtime do }} else _ -> - NextLS.Logger.error( - logger, - "Either failed to find the private cmd wrapper script or an `elixir`exe on your PATH" - ) + NextLS.Logger.error(logger, "Either failed to find the private cmd wrapper script") {:stop, :failed_to_boot} end @@ -283,6 +292,12 @@ defmodule NextLS.Runtime do end end + def handle_call({:expand, ast, file}, _from, %{node: node} = state) do + NextLS.Logger.info(state.logger, "expanding on the runtime node") + reply = :rpc.call(node, :_next_ls_private_spitfire_env, :expand, [ast, file]) + {:reply, {:ok, reply}, state} + end + def handle_call({:compile, opts}, _from, %{node: node} = state) do opts = opts @@ -298,7 +313,7 @@ defmodule NextLS.Runtime do end @impl GenServer - # NOTE: these two callbacks are basically to forward the messages from the runtime to the LSP + # NOTE: these two callbacks are basically to forward the messages from the runtime to the # LSP process so that progress messages can be dispatched def handle_info({:compiler_result, caller_ref, result}, state) do # we add the runtime name into the message @@ -316,12 +331,12 @@ defmodule NextLS.Runtime do state.on_initialized.({:error, :portdown}) end - {:stop, {:shutdown, :portdown}, state} + {:noreply, Map.delete(state, :node)} end def handle_info({:cancel, error}, state) do state.on_initialized.({:error, error}) - {:stop, error, state} + {:noreply, Map.delete(state, :node)} end def handle_info({:node, node}, state) do @@ -340,15 +355,17 @@ defmodule NextLS.Runtime do ) do NextLS.Logger.log(state.logger, data) + Port.close(port) state.on_initialized.({:error, :deps}) - {:noreply, state} + {:stop, {:shutdown, :unchecked_dependencies}, state} end def handle_info({port, {:data, "Unchecked dependencies" <> _ = data}}, %{port: port} = state) do NextLS.Logger.log(state.logger, data) + Port.close(port) state.on_initialized.({:error, :deps}) - {:noreply, state} + {:stop, {:shutdown, :unchecked_dependencies}, state} end def handle_info({port, {:data, data}}, %{port: port} = state) do diff --git a/lib/next_ls/runtime/bundled_elixir.ex b/lib/next_ls/runtime/bundled_elixir.ex new file mode 100644 index 00000000..6e864bf9 --- /dev/null +++ b/lib/next_ls/runtime/bundled_elixir.ex @@ -0,0 +1,61 @@ +defmodule NextLS.Runtime.BundledElixir do + @moduledoc """ + Module to install the bundled Elixir. + + The `@version` attribute corresponds to the last digit in the file name of the zip archive, they need to be incremented in lockstep. + """ + @version 1 + @base "~/.cache/elixir-tools/nextls" + @dir "elixir/1-17-#{@version}" + + def binpath(base \\ @base) do + Path.join([base, @dir, "bin"]) + end + + def mixpath(base \\ @base) do + Path.join([binpath(base), "mix"]) + end + + def path(base) do + Path.join([base, @dir]) + end + + def install(base, logger, opts \\ []) do + mixhome = Keyword.get(opts, :mix_home, Path.expand("~/.mix")) + binpath = binpath(base) + + unless File.exists?(binpath) do + extract_path = path(base) + File.mkdir_p!(base) + + priv_dir = :code.priv_dir(:next_ls) + bundled_elixir_path = ~c"#{Path.join(priv_dir, "precompiled-1-17-#{@version}.zip")}" + + :zip.unzip(bundled_elixir_path, cwd: ~c"#{extract_path}") + + for bin <- Path.wildcard(Path.join(binpath, "*")) do + File.chmod(bin, 0o755) + end + + new_path = "#{binpath}:#{System.get_env("PATH")}" + mixbin = mixpath(base) + + {_, 0} = + System.cmd(mixbin, ["local.rebar", "--force"], env: [{"PATH", new_path}, {"MIX_HOME", mixhome}]) + + {_, 0} = + System.cmd(mixbin, ["local.hex", "--force"], env: [{"PATH", new_path}, {"MIX_HOME", mixhome}]) + end + + :ok + rescue + e -> + NextLS.Logger.warning(logger, """ + Failed to unzip and install the bundled Elixir archive. + + #{Exception.format(:error, e, __STACKTRACE__)} + """) + + :error + end +end diff --git a/lib/next_ls/runtime/sidecar.ex b/lib/next_ls/runtime/sidecar.ex index 83adc419..fe6d0886 100644 --- a/lib/next_ls/runtime/sidecar.ex +++ b/lib/next_ls/runtime/sidecar.ex @@ -29,6 +29,9 @@ defmodule NextLS.Runtime.Sidecar do DB.insert_symbol(state.db, payload) {:noreply, state} + rescue + _ -> + {:noreply, state} end def handle_info({{:tracer, :reference, :alias}, payload}, state) do @@ -58,6 +61,9 @@ defmodule NextLS.Runtime.Sidecar do end {:noreply, state} + rescue + _ -> + {:noreply, state} end def handle_info({{:tracer, :reference, :attribute}, payload}, state) do @@ -69,22 +75,34 @@ defmodule NextLS.Runtime.Sidecar do end {:noreply, state} + rescue + _ -> + {:noreply, state} end def handle_info({{:tracer, :reference}, payload}, state) do DB.insert_reference(state.db, payload) {:noreply, state} + rescue + _ -> + {:noreply, state} end def handle_info({{:tracer, :start}, filename}, state) do DB.clean_references(state.db, filename) {:noreply, state} + rescue + _ -> + {:noreply, state} end def handle_info({{:tracer, :dbg}, payload}, state) do # credo:disable-for-next-line dbg(payload) {:noreply, state} + rescue + _ -> + {:noreply, state} end end diff --git a/lib/next_ls/snippet.ex b/lib/next_ls/snippet.ex index 22ac13fe..8003d614 100644 --- a/lib/next_ls/snippet.ex +++ b/lib/next_ls/snippet.ex @@ -182,6 +182,18 @@ defmodule NextLS.Snippet do } end + def get("fn/1", nil, _opts) do + %{ + kind: GenLSP.Enumerations.CompletionItemKind.snippet(), + insert_text_format: GenLSP.Enumerations.InsertTextFormat.snippet(), + insert_text: """ + fn $1 -> + $0 + end + """ + } + end + def get(_label, _trigger_character, _opts) do nil end diff --git a/mix.exs b/mix.exs index ee061a6f..44d844eb 100644 --- a/mix.exs +++ b/mix.exs @@ -6,7 +6,8 @@ defmodule NextLS.MixProject do def project do [ app: :next_ls, - description: "The language server for Elixir that just works. No longer published to Hex, please see our GitHub Releases for downloads.", + description: + "The language server for Elixir that just works. No longer published to Hex, please see our GitHub Releases for downloads.", version: @version, elixir: "~> 1.13", elixirc_paths: elixirc_paths(Mix.env()), @@ -40,13 +41,14 @@ defmodule NextLS.MixProject do next_ls: [ steps: [:assemble, &Burrito.wrap/1], burrito: [ - targets: inject_custom_erts([ - darwin_arm64: [os: :darwin, cpu: :aarch64], - darwin_amd64: [os: :darwin, cpu: :x86_64], - linux_arm64: [os: :linux, cpu: :aarch64], - linux_amd64: [os: :linux, cpu: :x86_64], - windows_amd64: [os: :windows, cpu: :x86_64] - ]) + targets: + inject_custom_erts( + darwin_arm64: [os: :darwin, cpu: :aarch64], + darwin_amd64: [os: :darwin, cpu: :x86_64], + linux_arm64: [os: :linux, cpu: :aarch64], + linux_amd64: [os: :linux, cpu: :x86_64], + windows_amd64: [os: :windows, cpu: :x86_64] + ) ] ] ] @@ -64,8 +66,8 @@ defmodule NextLS.MixProject do {:req, "~> 0.3"}, {:schematic, "~> 0.2"}, {:spitfire, github: "elixir-tools/spitfire"}, + # {:spitfire, path: "../spitfire"}, {:sourceror, "~> 1.0"}, - {:opentelemetry, "~> 1.3"}, {:opentelemetry_api, "~> 1.2"}, {:opentelemetry_exporter, "~> 1.4"}, diff --git a/mix.lock b/mix.lock index 8b809bb6..ff5ceb48 100644 --- a/mix.lock +++ b/mix.lock @@ -47,7 +47,7 @@ "req": {:hex, :req, "0.4.0", "1c759054dd64ef1b1a0e475c2d2543250d18f08395d3174c371b7746984579ce", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.9", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "f53eadc32ebefd3e5d50390356ec3a59ed2b8513f7da8c6c3f2e14040e9fe989"}, "schematic": {:hex, :schematic, "0.2.1", "0b091df94146fd15a0a343d1bd179a6c5a58562527746dadd09477311698dbb1", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0b255d65921e38006138201cd4263fd8bb807d9dfc511074615cd264a571b3b1"}, "sourceror": {:hex, :sourceror, "1.0.2", "c5e86fdc14881f797749d1fe5df017ca66727a8146e7ee3e736605a3df78f3e6", [:mix], [], "hexpm", "832335e87d0913658f129d58b2a7dc0490ddd4487b02de6d85bca0169ec2bd79"}, - "spitfire": {:git, "https://github.com/elixir-tools/spitfire.git", "d39f91d6e66f70cb3093bad4947d7e46bba40b43", []}, + "spitfire": {:git, "https://github.com/elixir-tools/spitfire.git", "af84760913b8908648eeb7f50ed90bd30ccaa9ed", []}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "styler": {:hex, :styler, "0.8.1", "f3c0f65023e4bfbf7e7aa752d128b8475fdabfd30f96ee7314b84480cc56e788", [:mix], [], "hexpm", "1aa48d3aa689a639289af3d8254d40e068e98c083d6e5e3d1a695e71a147b344"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, diff --git a/priv/monkey/_next_ls_private_compiler.ex b/priv/monkey/_next_ls_private_compiler.ex index 3eab0d60..bfe96131 100644 --- a/priv/monkey/_next_ls_private_compiler.ex +++ b/priv/monkey/_next_ls_private_compiler.ex @@ -1018,6 +1018,7 @@ defmodule :_next_ls_private_compiler_worker do if opts[:force] do File.rm_rf!(Path.join(opts[:working_dir], ".elixir-tools/_build")) + File.rm_rf!(Path.join(opts[:working_dir], ".elixir-tools/_build2")) end result = :_next_ls_private_compiler.compile() @@ -1074,3 +1075,466 @@ defmodule :_next_ls_private_compiler do e -> {:error, e} end end + +if Version.match?(System.version(), ">= 1.17.0-dev") do + # vendored from github.com/elixir-tools/spitfire, to avoid any namespacing conflicts, and make + # it easier to load into the runtime node. + # originally taken from https://gist.github.com/josevalim/3007fdbc5d56d79f15adedf7821620f3 and... expanded upon. + defmodule :_next_ls_private_spitfire_env do + @moduledoc false + + @env %{ + Macro.Env.prune_compile_info(__ENV__) + | line: 0, + file: "nofile", + module: nil, + function: nil, + context_modules: [] + } + defp env, do: @env + + @spec expand(Macro.t(), String.t()) :: {Macro.t(), map(), Macro.Env.t(), Macro.Env.t()} + def expand(ast, file) do + env = env() + + {ast, state, env} = + expand( + ast, + %{functions: %{}, macros: %{}, attrs: []}, + %{env | file: file} + ) + + {cursor_state, cursor_env} = + Process.get(:cursor_env, {Map.new(), env()}) + + cursor_env = + Map.merge( + Map.from_struct(cursor_env), + %{ + functions: + Enum.filter(Map.get(state, :functions, []), fn {m, _} -> m == cursor_env.module end) ++ + cursor_env.functions, + macros: + Enum.filter(Map.get(state, :macros, []), fn {m, _} -> m == cursor_env.module end) ++ cursor_env.macros, + attrs: Map.get(cursor_state, :attrs, []) + } + ) + + {ast, state, env, cursor_env} + end + + defp expand({:__cursor__, _meta, _} = node, state, env) do + Process.put(:cursor_env, {state, env}) + {node, state, env} + end + + defp expand({{:., _, [_, :__cursor__]}, _, _} = node, state, env) do + Process.put(:cursor_env, {state, env}) + {node, state, env} + end + + defp expand({:@, _, [{:__cursor__, _, _}]} = node, state, env) do + Process.put(:cursor_env, {state, env}) + {node, state, env} + end + + defp expand([_ | _] = list, state, env) do + expand_list(list, state, env) + end + + defp expand({left, right}, state, env) do + {left, state, env} = expand(left, state, env) + {right, state, env} = expand(right, state, env) + {{left, right}, state, env} + end + + defp expand({:{}, meta, args}, state, env) do + {args, state, env} = expand_list(args, state, env) + {{:{}, meta, args}, state, env} + end + + defp expand({:%{}, meta, args}, state, env) do + {args, state, env} = expand_list(args, state, env) + {{:%{}, meta, args}, state, env} + end + + defp expand({:|, meta, [left, right]}, state, env) do + {left, state, env} = expand(left, state, env) + {right, state, env} = expand(right, state, env) + {{:|, meta, [left, right]}, state, env} + end + + defp expand({:<<>>, meta, args}, state, env) do + {args, state, env} = expand_list(args, state, env) + {{:<<>>, meta, args}, state, env} + end + + ## __block__ + + defp expand({:__block__, _, list}, state, env) do + expand_list(list, state, env) + end + + ## __aliases__ + + defp expand({:__aliases__, meta, [head | tail] = list}, state, env) do + case Macro.Env.expand_alias(env, meta, list, trace: false) do + {:alias, alias} -> + # A compiler may want to emit a :local_function trace in here. + # Elixir also warns on easy to confuse aliases, such as True/False/Nil. + {alias, state, env} + + :error -> + {head, state, env} = expand(head, state, env) + + if is_atom(head) do + # A compiler may want to emit a :local_function trace in here. + {Module.concat([head | tail]), state, env} + else + {{:__aliases__, meta, [head | tail]}, state, env} + end + end + end + + ## require, alias, import + # Those are the main special forms and they require some care. + # + # First of all, if __aliases__ is changed to emit traces (which a + # custom compiler should), we should not emit traces when expanding + # the first argument of require/alias/import. + # + # Second, we must never expand the alias in `:as`. This is handled + # below. + # + # Finally, multi-alias/import/require, such as alias Foo.Bar.{Baz, Bat} + # is not implemented, check elixir_expand.erl on how to implement it. + + defp expand({form, meta, [arg]}, state, env) when form in [:require, :alias, :import] do + expand({form, meta, [arg, []]}, state, env) + end + + defp expand({:alias, meta, [arg, opts]}, state, env) do + {arg, state, env} = expand(arg, state, env) + {opts, state, env} = expand_directive_opts(opts, state, env) + + # An actual compiler would raise if the alias fails. + case Macro.Env.define_alias(env, meta, arg, [trace: false] ++ opts) do + {:ok, env} -> {arg, state, env} + {:error, _} -> {arg, state, env} + end + end + + defp expand({:require, meta, [arg, opts]}, state, env) do + {arg, state, env} = expand(arg, state, env) + {opts, state, env} = expand_directive_opts(opts, state, env) + + # An actual compiler would raise if the module is not defined or if the require fails. + case Macro.Env.define_require(env, meta, arg, [trace: false] ++ opts) do + {:ok, env} -> {arg, state, env} + {:error, _} -> {arg, state, env} + end + end + + defp expand({:import, meta, [arg, opts]}, state, env) do + {arg, state, env} = expand(arg, state, env) + {opts, state, env} = expand_directive_opts(opts, state, env) + + # An actual compiler would raise if the module is not defined or if the import fails. + with true <- is_atom(arg) and Code.ensure_loaded?(arg), + {:ok, env} <- Macro.Env.define_import(env, meta, arg, [trace: false] ++ opts) do + {arg, state, env} + else + _ -> {arg, state, env} + end + end + + ## =/2 + # We include = as an example of how we could handle variables. + # For example, if you want to store where variables are defined, + # you would collect this information in expand_pattern/3 and + # invoke it from all relevant places (such as case, cond, try, etc). + + defp expand({:=, meta, [left, right]}, state, env) do + {left, state, env} = expand_pattern(left, state, env) + {right, state, env} = expand(right, state, env) + {{:=, meta, [left, right]}, state, env} + end + + ## quote/1, quote/2 + # We need to expand options and look inside unquote/unquote_splicing. + # A custom compiler may want to raise on this special form (for example), + # quoted expressions make no sense if you are writing a language that + # compiles to C. + + defp expand({:quote, _, [opts]}, state, env) do + {block, opts} = Keyword.pop(opts, :do) + {_opts, state, env} = expand_list(opts, state, env) + expand_quote(block, state, env) + end + + defp expand({:quote, _, [opts, block_opts]}, state, env) do + {_opts, state, env} = expand_list(opts, state, env) + expand_quote(Keyword.get(block_opts, :do), state, env) + end + + ## Pin operator + # It only appears inside match and it disables the match behaviour. + + defp expand({:^, meta, [arg]}, state, %{context: context} = env) do + {arg, state, env} = expand(arg, state, %{env | context: nil}) + {{:^, meta, [arg]}, state, %{env | context: context}} + end + + ## Remote call + + defp expand({{:., dot_meta, [module, fun]}, meta, args}, state, env) when is_atom(fun) and is_list(args) do + {module, state, env} = expand(module, state, env) + arity = length(args) + + if is_atom(module) do + case Macro.Env.expand_require(env, meta, module, fun, arity, + trace: false, + check_deprecations: false + ) do + {:macro, module, callback} -> + expand_macro(meta, module, fun, args, callback, state, env) + + :error -> + expand_remote(meta, module, fun, args, state, env) + end + else + {{{:., dot_meta, [module, fun]}, meta, args}, state, env} + end + end + + ## Imported or local call + + defp expand({fun, meta, args}, state, env) when is_atom(fun) and is_list(args) do + arity = length(args) + + # For language servers, we don't want to emit traces, nor expand local macros, + # nor print deprecation warnings. Compilers likely want those set to true. + case Macro.Env.expand_import(env, meta, fun, arity, + trace: false, + allow_locals: false, + check_deprecations: false + ) do + {:macro, module, callback} -> + expand_macro(meta, module, fun, args, callback, state, env) + + {:function, module, fun} -> + expand_remote(meta, module, fun, args, state, env) + + :error -> + expand_local(meta, fun, args, state, env) + end + end + + ## __MODULE__, __DIR__, __ENV__, __CALLER__ + # A custom compiler may want to raise. + + defp expand({:__MODULE__, _, ctx}, state, env) when is_atom(ctx) do + {env.module, state, env} + end + + defp expand({:__DIR__, _, ctx}, state, env) when is_atom(ctx) do + {Path.dirname(env.file), state, env} + end + + defp expand({:__ENV__, _, ctx}, state, env) when is_atom(ctx) do + {Macro.escape(env), state, env} + end + + defp expand({:__CALLER__, _, ctx} = ast, state, env) when is_atom(ctx) do + {ast, state, env} + end + + ## var + # For the language server, we only want to capture definitions, + # we don't care when they are used. + + # defp expand({var, meta, ctx} = ast, state, %{context: :match} = env) when is_atom(var) and is_atom(ctx) do + # ctx = Keyword.get(meta, :context, ctx) + # # state = update_in(state.vars, &[{var, ctx} | &1]) + # {ast, state, env} + # end + + ## Fallback + + defp expand(ast, state, env) do + {ast, state, env} + end + + ## Macro handling + + # This is going to be the function where you will intercept expansions + # and attach custom behaviour. As an example, we will capture the module + # definition, fully replacing the actual implementation. You could also + # use this to capture module attributes (optionally delegating to the actual + # implementation), function expansion, and more. + defp expand_macro(_meta, Kernel, :defmodule, [alias, [do: block]], _callback, state, env) do + {expanded, state, env} = expand(alias, state, env) + + if is_atom(expanded) do + {full, env} = alias_defmodule(alias, expanded, env) + env = %{env | context_modules: [full | env.context_modules]} + + # The env inside the block is discarded. + {result, state, _env} = expand(block, state, %{env | module: full}) + {result, state, env} + else + # If we don't know the module name, do we still want to expand it here? + # Perhaps it would be useful for dealing with local functions anyway? + # But note that __MODULE__ will return nil. + # + # The env inside the block is discarded. + {result, state, _env} = expand(block, state, env) + {result, state, env} + end + end + + defp expand_macro(_meta, Kernel, type, [{name, _, params}, block], _callback, state, env) + when type in [:def, :defp] and is_tuple(block) and is_atom(name) and is_list(params) do + {res, state, _env} = expand(block, state, env) + + arity = length(List.wrap(params)) + functions = Map.update(state.functions, env.module, [{name, arity}], &Keyword.put_new(&1, name, arity)) + {res, put_in(state.functions, functions), env} + end + + defp expand_macro(_meta, Kernel, type, [{name, _, params}, block], _callback, state, env) + when type in [:defmacro, :defmacrop] do + {res, state, _env} = expand(block, state, env) + + arity = length(List.wrap(params)) + macros = Map.update(state.macros, env.module, [{name, arity}], &Keyword.put_new(&1, name, arity)) + {res, put_in(state.macros, macros), env} + end + + defp expand_macro(_meta, Kernel, type, [{name, _, params}, blocks], _callback, state, env) + when type in [:def, :defp] and is_atom(name) and is_list(params) and is_list(blocks) do + {blocks, state} = + for {type, block} <- blocks, reduce: {[], state} do + {acc, state} -> + {res, state, _env} = expand(block, state, env) + {[{type, res} | acc], state} + end + + arity = length(List.wrap(params)) + + functions = Map.update(state.functions, env.module, [{name, arity}], &Keyword.put_new(&1, name, arity)) + {Enum.reverse(blocks), put_in(state.functions, functions), env} + end + + defp expand_macro(meta, Kernel, :@, [{name, _, p}] = args, callback, state, env) when is_list(p) do + state = update_in(state.attrs, &[to_string(name) | &1]) + expand_macro_callback(meta, Kernel, :@, args, callback, state, env) + end + + defp expand_macro(meta, module, fun, args, callback, state, env) do + expand_macro_callback(meta, module, fun, args, callback, state, env) + end + + defp expand_macro_callback(meta, module, fun, args, callback, state, env) do + callback.(meta, args) + catch + :throw, other -> + throw(other) + + :error, _error -> + {{{:., meta, [module, fun]}, meta, args}, state, env} + else + ast -> + expand(ast, state, env) + end + + ## defmodule helpers + # defmodule automatically defines aliases, we need to mirror this feature here. + + # defmodule Elixir.Alias + defp alias_defmodule({:__aliases__, _, [:"Elixir", _ | _]}, module, env), do: {module, env} + + # defmodule Alias in root + defp alias_defmodule({:__aliases__, _, _}, module, %{module: nil} = env), do: {module, env} + + # defmodule Alias nested + defp alias_defmodule({:__aliases__, meta, [h | t]}, _module, env) when is_atom(h) do + module = Module.concat([env.module, h]) + alias = String.to_atom("Elixir." <> Atom.to_string(h)) + {:ok, env} = Macro.Env.define_alias(env, meta, module, as: alias, trace: false) + + case t do + [] -> {module, env} + _ -> {String.to_atom(Enum.join([module | t], ".")), env} + end + end + + # defmodule _ + defp alias_defmodule(_raw, module, env) do + {module, env} + end + + ## Helpers + + defp expand_remote(meta, module, fun, args, state, env) do + # A compiler may want to emit a :remote_function trace in here. + {args, state, env} = expand_list(args, state, env) + {{{:., meta, [module, fun]}, meta, args}, state, env} + end + + defp expand_local(meta, fun, args, state, env) do + # A compiler may want to emit a :local_function trace in here. + {args, state, env} = expand_list(args, state, env) + {{fun, meta, args}, state, env} + end + + defp expand_pattern(pattern, state, %{context: context} = env) do + {pattern, state, env} = expand(pattern, state, %{env | context: :match}) + {pattern, state, %{env | context: context}} + end + + defp expand_directive_opts(opts, state, env) do + opts = + Keyword.replace_lazy(opts, :as, fn + {:__aliases__, _, list} -> Module.concat(list) + other -> other + end) + + expand(opts, state, env) + end + + defp expand_list(ast, state, env), do: expand_list(ast, state, env, []) + + defp expand_list([], state, env, acc) do + {Enum.reverse(acc), state, env} + end + + defp expand_list([h | t], state, env, acc) do + {h, state, env} = expand(h, state, env) + expand_list(t, state, env, [h | acc]) + end + + defp expand_quote(ast, state, env) do + {_, {state, env}} = + Macro.prewalk(ast, {state, env}, fn + # We need to traverse inside unquotes + {unquote, _, [expr]}, {state, env} when unquote in [:unquote, :unquote_splicing] -> + {_expr, state, env} = expand(expr, state, env) + {:ok, {state, env}} + + # If we find a quote inside a quote, we stop traversing it + {:quote, _, [_]}, acc -> + {:ok, acc} + + {:quote, _, [_, _]}, acc -> + {:ok, acc} + + # Otherwise we go on + node, acc -> + {node, acc} + end) + + {ast, state, env} + end + end +end diff --git a/priv/precompiled-1-17-1.zip b/priv/precompiled-1-17-1.zip new file mode 100644 index 00000000..3e1d05dc Binary files /dev/null and b/priv/precompiled-1-17-1.zip differ diff --git a/test/next_ls/autocomplete_test.exs b/test/next_ls/autocomplete_test.exs index 79ec8410..048775f7 100644 --- a/test/next_ls/autocomplete_test.exs +++ b/test/next_ls/autocomplete_test.exs @@ -5,6 +5,8 @@ defmodule NextLS.AutocompleteTest do alias NextLS.Runtime + require Runtime + @moduletag :tmp_dir setup %{tmp_dir: tmp_dir} do @@ -64,6 +66,7 @@ defmodule NextLS.AutocompleteTest do task_supervisor: tvisor, working_dir: cwd, uri: "file://#{cwd}", + elixir_bin_path: "elixir" |> System.find_executable() |> Path.dirname(), parent: self(), lsp_pid: self(), logger: logger, @@ -83,12 +86,16 @@ defmodule NextLS.AutocompleteTest do [runtime: pid] end - defp expand(runtime, expr, env \\ %{variables: []}) do + defp base_env do + %{variables: [], aliases: [], functions: [{Kernel, [+: 1, +: 2, ++: 2, -: 1, -: 2, --: 2]}], macros: []} + end + + defp expand(runtime, expr, env \\ base_env()) do NextLS.Autocomplete.expand(Enum.reverse(expr), runtime, env) end test "Erlang module completion", %{runtime: runtime} do - assert expand(runtime, ~c":zl") == {:yes, [%{name: "zlib", kind: :module}]} + assert expand(runtime, ~c":zl") == {:yes, [%{name: "zlib", kind: :module, docs: "## \"zlib\"\n\n\n"}]} end test "Erlang module no completion", %{runtime: runtime} do @@ -97,28 +104,36 @@ defmodule NextLS.AutocompleteTest do test "Erlang module multiple values completion", %{runtime: runtime} do {:yes, list} = expand(runtime, ~c":logger") - assert %{name: "logger", kind: :module} in list - assert %{name: "logger_proxy", kind: :module} in list + + assert Enum.any?(list, fn c -> + match?(%{name: "logger", kind: :module}, c) + end) + + assert Enum.any?(list, fn c -> + match?(%{name: "logger_proxy", kind: :module}, c) + end) end test "Erlang root completion", %{runtime: runtime} do {:yes, list} = expand(runtime, ~c":") assert is_list(list) - assert %{name: "lists", kind: :module} in list - assert %{name: "Elixir.List", kind: :module} not in list + assert Enum.any?(list, fn c -> match?(%{name: "lists", kind: :module}, c) end) + refute Enum.any?(list, fn c -> match?(%{name: "Elixir.List", kind: :module}, c) end) end test "Elixir proxy", %{runtime: runtime} do - {:yes, list} = expand(runtime, ~c"E") - assert %{name: "Elixir", kind: :module} in list + {:yes, [elixir_entry | _list]} = expand(runtime, ~c"E") + assert %{name: "Elixir", kind: :module, docs: "## Elixir" <> _} = elixir_entry end test "Elixir completion", %{runtime: runtime} do - assert expand(runtime, ~c"En") == - {:yes, [%{name: "Enum", kind: :module}, %{name: "Enumerable", kind: :module}]} + assert {:yes, + [ + %{name: "Enum", kind: :module, docs: "## Enum" <> _}, + %{name: "Enumerable", kind: :module, docs: "## Enumerable" <> _} + ]} = expand(runtime, ~c"En") - assert expand(runtime, ~c"Enumera") == - {:yes, [%{name: "Enumerable", kind: :module}]} + assert {:yes, [%{name: "Enumerable", kind: :module, docs: "## Enumerable" <> _}]} = expand(runtime, ~c"Enumera") end # test "Elixir type completion", %{runtime: runtime} do @@ -159,23 +174,28 @@ defmodule NextLS.AutocompleteTest do # end test "Elixir completion on modules from load path", %{runtime: runtime} do - assert expand(runtime, ~c"Str") == - {:yes, - [%{name: "Stream", kind: :module}, %{name: "String", kind: :module}, %{name: "StringIO", kind: :module}]} + assert {:yes, + [%{name: "Stream", kind: :module}, %{name: "String", kind: :module}, %{name: "StringIO", kind: :module}]} = + expand(runtime, ~c"Str") - assert expand(runtime, ~c"Ma") == - {:yes, - [ - %{name: "Macro", kind: :module}, - %{name: "Map", kind: :module}, - %{name: "MapSet", kind: :module}, - %{name: "MatchError", kind: :module} - ]} + assert {:yes, + [ + %{name: "Macro", kind: :module}, + %{name: "Map", kind: :module}, + %{name: "MapSet", kind: :module}, + %{name: "MatchError", kind: :module} + ]} = expand(runtime, ~c"Ma") - assert expand(runtime, ~c"Dic") == {:yes, [%{name: "Dict", kind: :module}]} + assert {:yes, [%{name: "Dict", kind: :module}]} = expand(runtime, ~c"Dic") # 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"]} + # but i guess it does?? i'm confused + + assert {:yes, + [ + %{name: "ExUnit", kind: :module}, + %{name: "Exception", kind: :module} + ]} = expand(runtime, ~c"Ex") end # test "Elixir no completion for underscored functions with no doc", %{runtime: runtime} do @@ -252,11 +272,11 @@ defmodule NextLS.AutocompleteTest do end test "Elixir root submodule completion", %{runtime: runtime} do - assert expand(runtime, ~c"Elixir.Acce") == {:yes, [%{name: "Access", kind: :module}]} + {:yes, [%{name: "Access", kind: :module, docs: "## Access" <> _}]} = assert expand(runtime, ~c"Elixir.Acce") end test "Elixir submodule completion", %{runtime: runtime} do - assert expand(runtime, ~c"String.Cha") == {:yes, [%{name: "Chars", kind: :module}]} + assert {:yes, [%{name: "Chars", kind: :module}]} = expand(runtime, ~c"String.Cha") end test "Elixir submodule no completion", %{runtime: runtime} do @@ -318,7 +338,13 @@ defmodule NextLS.AutocompleteTest do end test "sigil completion", %{runtime: runtime} do - assert {:yes, sigils} = expand(runtime, ~c"~") + assert {:yes, sigils} = + expand( + runtime, + ~c"~", + Map.merge(base_env(), %{macros: [{Kernel, [sigil_c: 1, sigil_C: 1, sigil_r: 1, foo: 1]}]}) + ) + assert %{name: "C", kind: :sigil} in sigils assert {:yes, ~c"", sigils} = expand(runtime, ~c"~r") assert ~c"\"" in sigils @@ -405,20 +431,35 @@ defmodule NextLS.AutocompleteTest do end) end - test "kernel import completion", %{runtime: runtime} do - assert {:yes, [%{name: "defstruct", kind: :function, docs: _, arity: 1}]} = expand(runtime, ~c"defstru") + # test "kernel import completion", %{runtime: runtime} do + # assert {:yes, [%{name: "defstruct", kind: :function, docs: _, arity: 1}]} = expand(runtime, ~c"defstru") - assert {:yes, - [ - %{arity: 3, name: "put_elem", docs: _, kind: :function}, - %{arity: 2, name: "put_in", docs: _, kind: :function}, - %{arity: 3, name: "put_in", docs: _, kind: :function} - ]} = expand(runtime, ~c"put_") - end + # assert {:yes, + # [ + # %{arity: 3, name: "put_elem", docs: _, kind: :function}, + # %{arity: 2, name: "put_in", docs: _, kind: :function}, + # %{arity: 3, name: "put_in", docs: _, kind: :function} + # ]} = expand(runtime, ~c"put_") + # end test "variable name completion", %{runtime: runtime} do prev = "numeral = 3; number = 3; nothing = nil" - env = %{variables: ["numeral", "number", "nothing"]} + + env = + Map.merge(base_env(), %{ + variables: ["numeral", "number", "nothing"], + functions: [ + {Kernel, + [ + node: 0, + node: 1, + not: 1, + +: 1, + +: 2 + ]} + ] + }) + assert expand(runtime, ~c"#{prev}\nnumb", env) == {:yes, [%{name: "number", kind: :variable}]} assert expand(runtime, ~c"#{prev}\nnum", env) == @@ -492,19 +533,18 @@ defmodule NextLS.AutocompleteTest do end test "completion inside expression", %{runtime: runtime} do - assert expand(runtime, ~c"1 En") == - {:yes, [%{name: "Enum", kind: :module}, %{name: "Enumerable", kind: :module}]} + assert {:yes, [%{name: "Enum", kind: :module}, %{name: "Enumerable", kind: :module}]} = expand(runtime, ~c"1 En") - assert expand(runtime, ~c"Test(En") == - {:yes, [%{name: "Enum", kind: :module}, %{name: "Enumerable", kind: :module}]} + assert {:yes, [%{name: "Enum", kind: :module}, %{name: "Enumerable", kind: :module}]} = expand(runtime, ~c"Test(En") - assert expand(runtime, ~c"Test :zl") == {:yes, [%{name: "zlib", kind: :module}]} - assert expand(runtime, ~c"[:zl") == {:yes, [%{name: "zlib", kind: :module}]} - assert expand(runtime, ~c"{:zl") == {:yes, [%{name: "zlib", kind: :module}]} + assert {:yes, [%{name: "zlib", kind: :module}]} = expand(runtime, ~c"Test :zl") + assert {:yes, [%{name: "zlib", kind: :module}]} = expand(runtime, ~c"[:zl") + assert {:yes, [%{name: "zlib", kind: :module}]} = expand(runtime, ~c"{:zl") end test "Elixir completion sublevel", %{runtime: runtime} do - assert expand(runtime, ~c"SublevelTest.") == {:yes, [%{name: "LevelA", kind: :module}]} + assert expand(runtime, ~c"SublevelTest.") == + {:yes, [%{name: "LevelA", kind: :module, docs: "## SublevelTest.LevelA.LevelB\n\n\n"}]} end # TODO: aliases @@ -642,6 +682,7 @@ defmodule NextLS.AutocompleteTest do test "completion for aliases in special forms", %{runtime: runtime} do assert {:yes, entries} = expand(runtime, ~c"alias ") + entries = for e <- entries, do: Map.delete(e, :docs) assert %{name: "Atom", kind: :module} in entries refute %{name: "is_atom", kind: :function, arity: 1} in entries @@ -685,13 +726,13 @@ defmodule NextLS.AutocompleteTest do 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"\"./") == path_autocompletion(".", runtime) + assert expand(runtime, ~c"\"/") == path_autocompletion("/", runtime) 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}/") == path_autocompletion(dir, runtime) assert expand(runtime, ~c"\"#{dir}/sin") == {:yes, [%{name: "single1", kind: :file}]} assert expand(runtime, ~c"\"#{dir}/single1") == {:yes, [%{name: "single1", kind: :file}]} @@ -699,7 +740,7 @@ defmodule NextLS.AutocompleteTest do assert %{name: "file2", kind: :file} in files assert %{name: "file1", kind: :file} in files - assert expand(runtime, ~c"\"#{dir}/file") == path_autocompletion(dir, "file") + assert expand(runtime, ~c"\"#{dir}/file") == path_autocompletion(dir, runtime, "file") assert expand(runtime, ~c"\"#{dir}/d") == {:yes, [%{name: "dir/", kind: :dir}]} assert expand(runtime, ~c"\"#{dir}/dir") == {:yes, [%{name: "dir/", kind: :dir}]} @@ -707,12 +748,12 @@ defmodule NextLS.AutocompleteTest do assert %{name: "file4", kind: :file} in files assert %{name: "file3", kind: :file} in files - assert expand(runtime, ~c"\"#{dir}/dir/file") == dir |> Path.join("dir") |> path_autocompletion("file") + assert expand(runtime, ~c"\"#{dir}/dir/file") == dir |> Path.join("dir") |> path_autocompletion(runtime, "file") end - defp path_autocompletion(dir, hint \\ "") do - dir - |> File.ls!() + defp path_autocompletion(dir, runtime, hint \\ "") do + runtime + |> NextLS.Runtime.execute!(do: File.ls!(dir)) |> Stream.filter(&String.starts_with?(&1, hint)) |> Enum.map(fn file -> kind = if File.dir?(Path.join(dir, file)), do: :dir, else: :file diff --git a/test/next_ls/completions_test.exs b/test/next_ls/completions_test.exs index e90931d6..62472a67 100644 --- a/test/next_ls/completions_test.exs +++ b/test/next_ls/completions_test.exs @@ -1,6 +1,7 @@ defmodule NextLS.CompletionsTest do use ExUnit.Case, async: true + import ExUnit.CaptureLog import GenLSP.Test import NextLS.Support.Utils @@ -267,37 +268,44 @@ defmodule NextLS.CompletionsTest do end """) - request client, %{ - method: "textDocument/completion", - id: 2, - jsonrpc: "2.0", - params: %{ - textDocument: %{ - uri: uri - }, - position: %{ - line: 2, - character: 11 + {results, log} = + with_log(fn -> + request client, %{ + method: "textDocument/completion", + id: 2, + jsonrpc: "2.0", + params: %{ + textDocument: %{ + uri: uri + }, + position: %{ + line: 2, + character: 11 + } + } } - } - } - assert_result 2, [_, _] = results + assert_result 2, [_, _] = results + results + end) + + assert log =~ "Could not locate cursor" + assert log =~ "Source code that produced the above warning:" assert %{ "data" => nil, "documentation" => "", - "insertText" => "next_ls.ex", + "insertText" => "bar.ex", "kind" => 17, - "label" => "next_ls.ex" + "label" => "bar.ex" } in results assert %{ "data" => nil, "documentation" => "", - "insertText" => "next_ls/", - "kind" => 19, - "label" => "next_ls/" + "insertText" => "foo.ex", + "kind" => 17, + "label" => "foo.ex" } in results end diff --git a/test/next_ls/dependency_test.exs b/test/next_ls/dependency_test.exs index f7752fd5..5dc23407 100644 --- a/test/next_ls/dependency_test.exs +++ b/test/next_ls/dependency_test.exs @@ -1,7 +1,7 @@ defmodule NextLS.DependencyTest do - # FIXME: make async: true - use ExUnit.Case, async: false + use ExUnit.Case, async: true + import ExUnit.CaptureLog import GenLSP.Test import NextLS.Support.Utils @@ -53,6 +53,7 @@ defmodule NextLS.DependencyTest do end Process.unlink(context.server.lsp) + shutdown_client!(context.client) shutdown_server!(context.server) @@ -73,8 +74,21 @@ defmodule NextLS.DependencyTest do } """) - %{client: client} = context = Map.merge(context, Map.new(with_lsp(context))) - assert :ok == notify(client, %{method: "initialized", jsonrpc: "2.0", params: %{}}) + {%{client: client} = context, log} = + with_log(fn -> + %{client: client} = context = Map.merge(context, Map.new(with_lsp(context))) + assert :ok == notify(client, %{method: "initialized", jsonrpc: "2.0", params: %{}}) + + assert_notification "window/logMessage", %{ + "message" => "[Next LS] Unchecked dependencies" <> _, + "type" => 4 + } + + context + end) + + assert log =~ + "The runtime for Elixir.NextLS.DependencyTest-my_proj has crashed with reason: {:shutdown, :unchecked_dependencies}" assert_request(client, "window/showMessageRequest", fn params -> assert %{ @@ -100,7 +114,8 @@ defmodule NextLS.DependencyTest do } assert_is_ready(context, "my_proj") - assert_compiled(context, "my_proj") + + assert_compiled(context, "my_proj", 60_000) end test "successfully asks to refetch deps on compile", @@ -129,27 +144,38 @@ defmodule NextLS.DependencyTest do } """) - notify client, %{ - method: "textDocument/didSave", - jsonrpc: "2.0", - params: %{ - text: new_text, - textDocument: %{uri: uri(mixexs)} - } - } + {_, log} = + with_log(fn -> + notify client, %{ + method: "textDocument/didSave", + jsonrpc: "2.0", + params: %{ + text: new_text, + textDocument: %{uri: uri(mixexs)} + } + } - assert_request(client, "window/showMessageRequest", fn params -> - assert %{ - "type" => 1, - "actions" => [ - %{"title" => "yes"}, - %{"title" => "no"} - ] - } = params + assert_notification "window/logMessage", %{ + "message" => "[Next LS] Unchecked dependencies" <> _, + "type" => 4 + } - # respond with yes - %{"title" => "yes"} - end) + assert_request(client, "window/showMessageRequest", fn params -> + assert %{ + "type" => 1, + "actions" => [ + %{"title" => "yes"}, + %{"title" => "no"} + ] + } = params + + # respond with yes + %{"title" => "yes"} + end) + end) + + assert log =~ + "The runtime for Elixir.NextLS.DependencyTest-my_proj has crashed with reason: {:shutdown, :unchecked_dependencies}" assert_notification "window/logMessage", %{ "message" => "[Next LS] Running `mix deps.get` in directory" <> _, diff --git a/test/next_ls/extensions/credo_extension_test.exs b/test/next_ls/extensions/credo_extension_test.exs index c01a20d0..441a4c3f 100644 --- a/test/next_ls/extensions/credo_extension_test.exs +++ b/test/next_ls/extensions/credo_extension_test.exs @@ -2,8 +2,7 @@ defmodule NextLS.CredoExtensionTest do # this test installs and compiles credo from scratch everytime it runs # we need to determine a way to cache this without losing the utility of # the test. - # FIXME: make async: true - use ExUnit.Case, async: false + use ExUnit.Case, async: true import GenLSP.Test import NextLS.Support.Utils diff --git a/test/next_ls/runtime_test.exs b/test/next_ls/runtime_test.exs index caa763cd..ff3e3d6f 100644 --- a/test/next_ls/runtime_test.exs +++ b/test/next_ls/runtime_test.exs @@ -44,76 +44,6 @@ defmodule NextLs.RuntimeTest do [logger: logger, cwd: Path.absname(tmp_dir), on_init: on_init] end - describe "errors" do - # FIXME(zachallaun): make these not flaky on CI - @describetag :pending - test "emitted on crash during initialization", - %{tmp_dir: tmp_dir, logger: logger, cwd: cwd, on_init: on_init} do - # obvious syntax error - bad_mix_exs = String.replace(mix_exs(), "defmodule", "") - File.write!(Path.join(tmp_dir, "mix.exs"), bad_mix_exs) - - start_supervised!({Registry, keys: :duplicate, name: RuntimeTest.Registry}) - - tvisor = start_supervised!(Task.Supervisor) - - start_supervised!( - {Runtime, - task_supervisor: tvisor, - name: "my_proj", - on_initialized: on_init, - working_dir: cwd, - uri: "file://#{cwd}", - parent: self(), - logger: logger, - db: :some_db, - mix_env: "dev", - mix_target: "host", - registry: RuntimeTest.Registry}, - restart: :temporary - ) - - assert_receive {:error, :portdown} - - assert_receive {:log, :log, log_msg} - assert log_msg =~ "syntax error" - - assert_receive {:log, :error, error_msg} - assert error_msg =~ "{:shutdown, :portdown}" - end - - test "emitted on crash after initialization", - %{logger: logger, cwd: cwd, on_init: on_init} do - start_supervised!({Registry, keys: :duplicate, name: RuntimeTest.Registry}) - - tvisor = start_supervised!(Task.Supervisor) - - pid = - start_supervised!( - {Runtime, - task_supervisor: tvisor, - name: "my_proj", - on_initialized: on_init, - working_dir: cwd, - uri: "file://#{cwd}", - parent: self(), - logger: logger, - db: :some_db, - mix_env: "dev", - mix_target: "host", - registry: RuntimeTest.Registry}, - restart: :temporary - ) - - assert_receive :ready - - assert {:ok, {:badrpc, :nodedown}} = Runtime.call(pid, {System, :halt, [1]}) - - assert_receive {:log, :error, error_msg} - assert error_msg =~ "{:shutdown, :nodedown}" - end - end - describe "call/2" do test "responds with an ok tuple if the runtime has initialized", %{logger: logger, cwd: cwd, on_init: on_init} do @@ -128,6 +58,7 @@ defmodule NextLs.RuntimeTest do task_supervisor: tvisor, working_dir: cwd, uri: "file://#{cwd}", + elixir_bin_path: "elixir" |> System.find_executable() |> Path.dirname(), parent: self(), lsp_pid: self(), logger: logger, @@ -157,6 +88,7 @@ defmodule NextLs.RuntimeTest do on_initialized: on_init, working_dir: cwd, uri: "file://#{cwd}", + elixir_bin_path: "elixir" |> System.find_executable() |> Path.dirname(), parent: self(), lsp_pid: self(), logger: logger, @@ -187,6 +119,7 @@ defmodule NextLs.RuntimeTest do task_supervisor: tvisor, working_dir: cwd, uri: "file://#{cwd}", + elixir_bin_path: "elixir" |> System.find_executable() |> Path.dirname(), parent: self(), lsp_pid: self(), logger: logger, @@ -250,6 +183,7 @@ defmodule NextLs.RuntimeTest do on_initialized: on_init, working_dir: cwd, uri: "file://#{cwd}", + elixir_bin_path: "elixir" |> System.find_executable() |> Path.dirname(), parent: self(), lsp_pid: self(), logger: logger, diff --git a/test/next_ls_test.exs b/test/next_ls_test.exs index ffcb7cf6..23769f07 100644 --- a/test/next_ls_test.exs +++ b/test/next_ls_test.exs @@ -122,23 +122,6 @@ defmodule NextLSTest do end """) - request client, %{ - method: "textDocument/formatting", - id: 2, - jsonrpc: "2.0", - params: %{ - textDocument: %{ - uri: "file://#{cwd}/my_proj/lib/foo/bar.ex" - }, - options: %{ - insertSpaces: true, - tabSize: 2 - } - } - } - - assert_result 2, nil - assert_is_ready(context, "my_proj") request client, %{ @@ -432,6 +415,9 @@ defmodule NextLSTest do assert symbol in symbols + file = Path.join(cwd, "my_proj/lib/code_action.ex") + File.rm!(file) + notify(client, %{ method: "workspace/didChangeWatchedFiles", jsonrpc: "2.0", @@ -439,7 +425,7 @@ defmodule NextLSTest do changes: [ %{ type: GenLSP.Enumerations.FileChangeType.deleted(), - uri: "file://#{Path.join(cwd, "my_proj/lib/code_action.ex")}" + uri: "file://#{file}" } ] } diff --git a/test/support/utils.ex b/test/support/utils.ex index 719aca7a..fe063d22 100644 --- a/test/support/utils.ex +++ b/test/support/utils.ex @@ -40,6 +40,10 @@ defmodule NextLS.Support.Utils do Path.absname(Path.join(tmp_dir, path)) end + bundle_base = Path.join(tmp_dir, ".bundled") + mixhome = Path.join(tmp_dir, ".mix") + File.mkdir_p!(bundle_base) + tvisor = start_supervised!(Supervisor.child_spec(Task.Supervisor, id: :one)) r_tvisor = start_supervised!(Supervisor.child_spec(Task.Supervisor, id: :two)) rvisor = start_supervised!({DynamicSupervisor, [strategy: :one_for_one]}, id: :three) @@ -63,7 +67,9 @@ defmodule NextLS.Support.Utils do dynamic_supervisor: rvisor, registry: context.module, extensions: extensions, - cache: cache + cache: cache, + bundle_base: bundle_base, + mix_home: mixhome ) Process.link(server.lsp)