From c9041d82ec5c0b388e331261bd5d73de2aff8f13 Mon Sep 17 00:00:00 2001 From: Steve Cohen Date: Sat, 23 Dec 2023 17:43:52 -0800 Subject: [PATCH] Added reindex command (#522) * Added reindex command Added a reindex code lens so we can rebuild indexes without resorting to manual operations. As we build the indexing infrastructure out, we'll likely need to rebuild the index a lot. Presently, this means restarting the server, which can take some time, so I thought i'd add a command to do it instead. I tried using code actions, but this was a bit fraught, so instead, I used a code lens on the project definition in your mix.exs file. The code lens disappears when clicked and reappears when the indexing job is done. --- apps/proto/.formatter.exs | 1 + apps/proto/lib/lexical/proto.ex | 5 +- apps/proto/lib/lexical/proto/request.ex | 18 ++- .../lexical/protocol/types/code_lens.ex | 7 + .../protocol/types/code_lens/params.ex | 10 ++ .../protocol/types/execute_command/params.ex | 10 ++ .../execute_command/registration/options.ex | 6 + .../protocol/lib/lexical/protocol/requests.ex | 18 +++ .../lib/lexical/protocol/responses.ex | 11 ++ .../lib/lexical/remote_control/api.ex | 9 ++ .../lexical/remote_control/api/messages.ex | 14 ++ .../lib/lexical/remote_control/application.ex | 1 + .../remote_control/commands/reindex.ex | 149 ++++++++++++++++++ .../dispatch/handlers/indexing.ex | 9 +- .../lexical/remote_control/search/indexer.ex | 2 +- .../remote_control/commands/reindex_test.exs | 89 +++++++++++ .../dispatch/handlers/indexer_test.exs | 2 + .../lexical/server/project/search_listener.ex | 55 +++++++ .../lib/lexical/server/project/supervisor.ex | 4 +- .../lib/lexical/server/provider/handlers.ex | 6 + .../server/provider/handlers/code_lens.ex | 59 +++++++ .../server/provider/handlers/commands.ex | 60 +++++++ apps/server/lib/lexical/server/state.ex | 6 + apps/server/lib/lexical/server/window.ex | 40 +++-- .../provider/handlers/code_lens_test.exs | 99 ++++++++++++ 25 files changed, 668 insertions(+), 22 deletions(-) create mode 100644 apps/protocol/lib/generated/lexical/protocol/types/code_lens.ex create mode 100644 apps/protocol/lib/generated/lexical/protocol/types/code_lens/params.ex create mode 100644 apps/protocol/lib/generated/lexical/protocol/types/execute_command/params.ex create mode 100644 apps/protocol/lib/generated/lexical/protocol/types/execute_command/registration/options.ex create mode 100644 apps/remote_control/lib/lexical/remote_control/commands/reindex.ex create mode 100644 apps/remote_control/test/lexical/remote_control/commands/reindex_test.exs create mode 100644 apps/server/lib/lexical/server/project/search_listener.ex create mode 100644 apps/server/lib/lexical/server/provider/handlers/code_lens.ex create mode 100644 apps/server/lib/lexical/server/provider/handlers/commands.ex create mode 100644 apps/server/test/lexical/server/provider/handlers/code_lens_test.exs diff --git a/apps/proto/.formatter.exs b/apps/proto/.formatter.exs index f50e36deb..ff1e17901 100644 --- a/apps/proto/.formatter.exs +++ b/apps/proto/.formatter.exs @@ -8,6 +8,7 @@ proto_dsl = [ defrequest: 2, defresponse: 1, deftype: 1, + server_request: 2, server_request: 3 ] diff --git a/apps/proto/lib/lexical/proto.ex b/apps/proto/lib/lexical/proto.ex index 77c3a5104..5cd0aba6b 100644 --- a/apps/proto/lib/lexical/proto.ex +++ b/apps/proto/lib/lexical/proto.ex @@ -10,7 +10,10 @@ defmodule Lexical.Proto do import Proto.Alias, only: [defalias: 1] import Proto.Enum, only: [defenum: 1] import Proto.Notification, only: [defnotification: 1, defnotification: 2] - import Proto.Request, only: [defrequest: 1, defrequest: 2, server_request: 3] + + import Proto.Request, + only: [defrequest: 1, defrequest: 2, server_request: 2, server_request: 3] + import Proto.Response, only: [defresponse: 1] import Proto.Type, only: [deftype: 1] end diff --git a/apps/proto/lib/lexical/proto/request.ex b/apps/proto/lib/lexical/proto/request.ex index 73f7a76cf..72163551a 100644 --- a/apps/proto/lib/lexical/proto/request.ex +++ b/apps/proto/lib/lexical/proto/request.ex @@ -26,6 +26,16 @@ defmodule Lexical.Proto.Request do end end + defmacro server_request(method, response_module_ast) do + quote do + unquote(do_defrequest(method, [], __CALLER__)) + + def parse_response(response) do + unquote(response_module_ast).parse(response) + end + end + end + defp fetch_types(params_module_ast, env) do params_module = params_module_ast @@ -94,11 +104,17 @@ defmodule Lexical.Proto.Request do defimpl Jason.Encoder, for: unquote(lsp_module_name) do def encode(request, opts) do + params = + case Map.take(request, unquote(param_names)) do + empty when map_size(empty) == 0 -> nil + params -> params + end + %{ id: request.id, jsonrpc: "2.0", method: unquote(method), - params: Map.take(request, unquote(param_names)) + params: params } |> Jason.Encode.map(opts) end diff --git a/apps/protocol/lib/generated/lexical/protocol/types/code_lens.ex b/apps/protocol/lib/generated/lexical/protocol/types/code_lens.ex new file mode 100644 index 000000000..fb2cff13e --- /dev/null +++ b/apps/protocol/lib/generated/lexical/protocol/types/code_lens.ex @@ -0,0 +1,7 @@ +# This file's contents are auto-generated. Do not edit. +defmodule Lexical.Protocol.Types.CodeLens do + alias Lexical.Proto + alias Lexical.Protocol.Types + use Proto + deftype command: optional(Types.Command), data: optional(any()), range: Types.Range +end diff --git a/apps/protocol/lib/generated/lexical/protocol/types/code_lens/params.ex b/apps/protocol/lib/generated/lexical/protocol/types/code_lens/params.ex new file mode 100644 index 000000000..d23491513 --- /dev/null +++ b/apps/protocol/lib/generated/lexical/protocol/types/code_lens/params.ex @@ -0,0 +1,10 @@ +# This file's contents are auto-generated. Do not edit. +defmodule Lexical.Protocol.Types.CodeLens.Params do + alias Lexical.Proto + alias Lexical.Protocol.Types + use Proto + + deftype partial_result_token: optional(Types.Progress.Token), + text_document: Types.TextDocument.Identifier, + work_done_token: optional(Types.Progress.Token) +end diff --git a/apps/protocol/lib/generated/lexical/protocol/types/execute_command/params.ex b/apps/protocol/lib/generated/lexical/protocol/types/execute_command/params.ex new file mode 100644 index 000000000..b378f92d8 --- /dev/null +++ b/apps/protocol/lib/generated/lexical/protocol/types/execute_command/params.ex @@ -0,0 +1,10 @@ +# This file's contents are auto-generated. Do not edit. +defmodule Lexical.Protocol.Types.ExecuteCommand.Params do + alias Lexical.Proto + alias Lexical.Protocol.Types + use Proto + + deftype arguments: optional(list_of(any())), + command: string(), + work_done_token: optional(Types.Progress.Token) +end diff --git a/apps/protocol/lib/generated/lexical/protocol/types/execute_command/registration/options.ex b/apps/protocol/lib/generated/lexical/protocol/types/execute_command/registration/options.ex new file mode 100644 index 000000000..c9df46a07 --- /dev/null +++ b/apps/protocol/lib/generated/lexical/protocol/types/execute_command/registration/options.ex @@ -0,0 +1,6 @@ +# This file's contents are auto-generated. Do not edit. +defmodule Lexical.Protocol.Types.ExecuteCommand.Registration.Options do + alias Lexical.Proto + use Proto + deftype commands: list_of(string()), work_done_progress: optional(boolean()) +end diff --git a/apps/protocol/lib/lexical/protocol/requests.ex b/apps/protocol/lib/lexical/protocol/requests.ex index 82e3c4f66..e6a829915 100644 --- a/apps/protocol/lib/lexical/protocol/requests.ex +++ b/apps/protocol/lib/lexical/protocol/requests.ex @@ -52,6 +52,12 @@ defmodule Lexical.Protocol.Requests do defrequest "textDocument/codeAction", Types.CodeAction.Params end + defmodule CodeLens do + use Proto + + defrequest "textDocument/codeLens", Types.CodeLens.Params + end + defmodule Completion do use Proto @@ -64,6 +70,12 @@ defmodule Lexical.Protocol.Requests do defrequest "textDocument/hover", Types.Hover.Params end + defmodule ExecuteCommand do + use Proto + + defrequest "workspace/executeCommand", Types.ExecuteCommand.Params + end + # Server -> Client requests defmodule RegisterCapability do @@ -80,5 +92,11 @@ defmodule Lexical.Protocol.Requests do Responses.ShowMessage end + defmodule CodeLensRefresh do + use Proto + + server_request "workspace/codeLens/refresh", Responses.Empty + end + use Proto, decoders: :requests end diff --git a/apps/protocol/lib/lexical/protocol/responses.ex b/apps/protocol/lib/lexical/protocol/responses.ex index 71d4b7d6a..a7ef22ae2 100644 --- a/apps/protocol/lib/lexical/protocol/responses.ex +++ b/apps/protocol/lib/lexical/protocol/responses.ex @@ -39,6 +39,11 @@ defmodule Lexical.Protocol.Responses do defresponse optional(list_of(Types.CodeAction)) end + defmodule CodeLens do + use Proto + defresponse optional(list_of(Types.CodeLens)) + end + defmodule Completion do use Proto @@ -57,6 +62,12 @@ defmodule Lexical.Protocol.Responses do defresponse optional(Types.Hover) end + defmodule ExecuteCommand do + use Proto + + defresponse optional(any()) + end + # Client -> Server responses defmodule ShowMessage do diff --git a/apps/remote_control/lib/lexical/remote_control/api.ex b/apps/remote_control/lib/lexical/remote_control/api.ex index acc5fd09d..80c494d77 100644 --- a/apps/remote_control/lib/lexical/remote_control/api.ex +++ b/apps/remote_control/lib/lexical/remote_control/api.ex @@ -9,6 +9,7 @@ defmodule Lexical.RemoteControl.Api do alias Lexical.RemoteControl.CodeAction alias Lexical.RemoteControl.CodeIntelligence alias Lexical.RemoteControl.CodeMod + alias Lexical.RemoteControl.Commands require Logger @@ -113,4 +114,12 @@ defmodule Lexical.RemoteControl.Api do def broadcast(%Project{} = project, message) do RemoteControl.call(project, RemoteControl.Dispatch, :broadcast, [message]) end + + def reindex(%Project{} = project) do + RemoteControl.call(project, Commands.Reindex, :perform, [project]) + end + + def index_running?(%Project{} = project) do + RemoteControl.call(project, Commands.Reindex, :running?, []) + end end diff --git a/apps/remote_control/lib/lexical/remote_control/api/messages.ex b/apps/remote_control/lib/lexical/remote_control/api/messages.ex index d844b38a4..f60325a36 100644 --- a/apps/remote_control/lib/lexical/remote_control/api/messages.ex +++ b/apps/remote_control/lib/lexical/remote_control/api/messages.ex @@ -39,6 +39,10 @@ defmodule Lexical.RemoteControl.Api.Messages do defrecord :struct_discovered, module: nil, fields: [] + defrecord :project_reindex_requested, project: nil + + defrecord :project_reindexed, project: nil, elapsed_ms: 0, status: :success + @type compile_status :: :successful | :error @type name_and_arity :: {atom, non_neg_integer} @type field_list :: Keyword.t() | [atom] @@ -118,4 +122,14 @@ defmodule Lexical.RemoteControl.Api.Messages do ) @type struct_discovered :: record(:struct_discovered, module: module(), fields: field_list()) + + @type project_reindex_requested :: + record(:project_reindex_requested, project: Lexical.Project.t()) + + @type project_reindexed :: + record(:project_reindexed, + project: Lexical.Project.t(), + elapsed_ms: non_neg_integer(), + status: :success | {:error, term()} + ) end diff --git a/apps/remote_control/lib/lexical/remote_control/application.ex b/apps/remote_control/lib/lexical/remote_control/application.ex index 6dae97e89..bb7b2707a 100644 --- a/apps/remote_control/lib/lexical/remote_control/application.ex +++ b/apps/remote_control/lib/lexical/remote_control/application.ex @@ -16,6 +16,7 @@ defmodule Lexical.RemoteControl.Application do children = if RemoteControl.project_node?() do [ + {RemoteControl.Commands.Reindex, nil}, RemoteControl.Module.Loader, {RemoteControl.Dispatch, progress: true}, RemoteControl.ModuleMappings, diff --git a/apps/remote_control/lib/lexical/remote_control/commands/reindex.ex b/apps/remote_control/lib/lexical/remote_control/commands/reindex.ex new file mode 100644 index 000000000..697b6b53e --- /dev/null +++ b/apps/remote_control/lib/lexical/remote_control/commands/reindex.ex @@ -0,0 +1,149 @@ +defmodule Lexical.RemoteControl.Commands.Reindex do + defmodule State do + alias Lexical.Ast.Analysis + alias Lexical.Document + alias Lexical.RemoteControl.Search + alias Lexical.RemoteControl.Search.Indexer + + require Logger + defstruct reindex_fun: nil, index_task: nil, pending_updates: %{} + + def new(reindex_fun) do + %__MODULE__{reindex_fun: reindex_fun} + end + + def set_task(%__MODULE__{} = state, {_, _} = task) do + %__MODULE__{state | index_task: task} + end + + def clear_task(%__MODULE__{} = state) do + %__MODULE__{state | index_task: nil} + end + + def reindex_uri(%__MODULE__{index_task: nil} = state, uri) do + case entries_for_uri(uri) do + {:ok, path, entries} -> + Search.Store.update(path, entries) + + _ -> + :ok + end + + state + end + + def reindex_uri(%__MODULE__{} = state, uri) do + case entries_for_uri(uri) do + {:ok, path, entries} -> + put_in(state.pending_updates[path], entries) + + _ -> + state + end + end + + def flush_pending_updates(%__MODULE__{} = state) do + Enum.each(state.pending_updates, fn {path, entries} -> + Search.Store.update(path, entries) + end) + + %__MODULE__{state | pending_updates: %{}} + end + + defp entries_for_uri(uri) do + with {:ok, %Document{} = document, %Analysis{} = analysis} <- + Document.Store.fetch(uri, :analysis), + {:ok, entries} <- Indexer.Quoted.index(analysis) do + {:ok, document.path, entries} + else + error -> + Logger.error("Could not update index because #{inspect(error)}") + error + end + end + end + + @moduledoc """ + A simple genserver that prevents more than one reindexing job from running at the same time + """ + + alias Lexical.Document + alias Lexical.Project + alias Lexical.RemoteControl.Api + alias Lexical.RemoteControl.Dispatch + alias Lexical.RemoteControl.Search + + use GenServer + import Api.Messages + + def start_link(reindex_fun) when is_function(reindex_fun, 1) do + GenServer.start_link(__MODULE__, reindex_fun, name: __MODULE__) + end + + def start_link(_) do + start_link(&do_reindex/1) + end + + def uri(uri) do + GenServer.cast(__MODULE__, {:reindex_uri, uri}) + end + + def perform(%Project{} = project) do + GenServer.call(__MODULE__, {:perform, project}) + end + + def running? do + GenServer.call(__MODULE__, :running?) + end + + @impl GenServer + def init(reindex_fun) do + {:ok, State.new(reindex_fun)} + end + + @impl GenServer + def handle_call(:running?, _from, %State{index_task: index_task} = state) do + {:reply, match?({_, _}, index_task), state} + end + + def handle_call({:perform, project}, _from, %State{index_task: nil} = state) do + index_task = spawn_monitor(fn -> state.reindex_fun.(project) end) + {:reply, :ok, State.set_task(state, index_task)} + end + + def handle_call({:perform, _project}, _from, state) do + {:reply, {:error, "Already Running"}, state} + end + + @impl GenServer + def handle_cast({:reindex_uri, uri}, %State{} = state) do + {:noreply, State.reindex_uri(state, uri)} + end + + @impl GenServer + def handle_info({:DOWN, ref, :process, pid, _reason}, %State{index_task: {pid, ref}} = state) do + new_state = + state + |> State.flush_pending_updates() + |> State.clear_task() + + {:noreply, new_state} + end + + defp do_reindex(%Project{} = project) do + Dispatch.broadcast(project_reindex_requested(project: project)) + + {elapsed_us, result} = + :timer.tc(fn -> + with {:ok, entries} <- Search.Indexer.create_index(project) do + Search.Store.replace(entries) + end + end) + + Dispatch.broadcast( + project_reindexed(project: project, elapsed_ms: round(elapsed_us / 1000), status: :success) + ) + + result + end +end diff --git a/apps/remote_control/lib/lexical/remote_control/dispatch/handlers/indexing.ex b/apps/remote_control/lib/lexical/remote_control/dispatch/handlers/indexing.ex index 6825d2b5b..f20ec4bd3 100644 --- a/apps/remote_control/lib/lexical/remote_control/dispatch/handlers/indexing.ex +++ b/apps/remote_control/lib/lexical/remote_control/dispatch/handlers/indexing.ex @@ -1,10 +1,9 @@ defmodule Lexical.RemoteControl.Dispatch.Handlers.Indexing do - alias Lexical.Ast.Analysis alias Lexical.Document alias Lexical.RemoteControl.Api.Messages + alias Lexical.RemoteControl.Commands alias Lexical.RemoteControl.Dispatch alias Lexical.RemoteControl.Search - alias Lexical.RemoteControl.Search.Indexer require Logger import Messages @@ -26,11 +25,7 @@ defmodule Lexical.RemoteControl.Dispatch.Handlers.Indexing do end defp reindex(uri) do - with {:ok, %Document{} = document, %Analysis{} = analysis} <- - Document.Store.fetch(uri, :analysis), - {:ok, entries} <- Indexer.Quoted.index(analysis) do - Search.Store.update(document.path, entries) - end + Commands.Reindex.uri(uri) end def delete_path(uri) do diff --git a/apps/remote_control/lib/lexical/remote_control/search/indexer.ex b/apps/remote_control/lib/lexical/remote_control/search/indexer.ex index ea8673d34..ac5cfdf3d 100644 --- a/apps/remote_control/lib/lexical/remote_control/search/indexer.ex +++ b/apps/remote_control/lib/lexical/remote_control/search/indexer.ex @@ -107,7 +107,7 @@ defmodule Lexical.RemoteControl.Search.Indexer do fn chunk -> block_bytes = chunk |> Enum.map(&Map.get(path_to_size_map, &1)) |> Enum.sum() result = Enum.map(chunk, processor) - update_progress.(block_bytes, nil) + update_progress.(block_bytes, "Indexing") result end, timeout: timeout diff --git a/apps/remote_control/test/lexical/remote_control/commands/reindex_test.exs b/apps/remote_control/test/lexical/remote_control/commands/reindex_test.exs new file mode 100644 index 000000000..7a53def6a --- /dev/null +++ b/apps/remote_control/test/lexical/remote_control/commands/reindex_test.exs @@ -0,0 +1,89 @@ +defmodule Lexical.RemoteControl.Commands.ReindexTest do + alias Lexical.Document + alias Lexical.RemoteControl.Commands.Reindex + alias Lexical.RemoteControl.Search + + import Lexical.Test.EventualAssertions + import Lexical.Test.Fixtures + import Lexical.Test.Entry.Builder + + use ExUnit.Case + use Patch + + setup do + reindex_fun = fn _ -> + Process.sleep(20) + end + + start_supervised!({Reindex, reindex_fun}) + + {:ok, project: project()} + end + + test "it should allow reindexing", %{project: project} do + assert :ok = Reindex.perform(project) + assert Reindex.running?() + end + + test "it fails if another index is running", %{project: project} do + assert :ok = Reindex.perform(project) + assert {:error, "Already Running"} = Reindex.perform(project) + end + + test "it eventually becomes available", %{project: project} do + assert :ok = Reindex.perform(project) + refute_eventually Reindex.running?() + end + + test "another reindex can be enqueued", %{project: project} do + assert :ok = Reindex.perform(project) + assert_eventually :ok = Reindex.perform(project) + end + + def put_entries(uri, entries) do + Process.put(uri, entries) + end + + describe "uri/1" do + setup do + test = self() + + patch(Reindex.State, :entries_for_uri, fn uri -> + entries = + test + |> Process.info() + |> get_in([:dictionary]) + |> Enum.find_value(fn + {^uri, value} -> value + _ -> nil + end) + + {:ok, Document.Path.ensure_path(uri), entries || []} + end) + + patch(Search.Store, :update, fn uri, entries -> + send(test, {:entries, uri, entries}) + end) + + :ok + end + + test "reindexes a specific uri" do + uri = "file:///file.ex" + entries = [reference()] + put_entries(uri, entries) + Reindex.uri(uri) + assert_receive {:entries, "/file.ex", ^entries} + end + + test "buffers updates if a reindex is in progress", %{project: project} do + uri = "file:///file.ex" + new_entries = [reference(), definition()] + put_entries(uri, new_entries) + Reindex.perform(project) + Reindex.uri(uri) + + assert_receive {:entries, "/file.ex", ^new_entries} + end + end +end diff --git a/apps/remote_control/test/lexical/remote_control/dispatch/handlers/indexer_test.exs b/apps/remote_control/test/lexical/remote_control/dispatch/handlers/indexer_test.exs index f80da84fa..a682c95d3 100644 --- a/apps/remote_control/test/lexical/remote_control/dispatch/handlers/indexer_test.exs +++ b/apps/remote_control/test/lexical/remote_control/dispatch/handlers/indexer_test.exs @@ -2,6 +2,7 @@ defmodule Lexical.RemoteControl.Dispatch.Handlers.IndexingTest do alias Lexical.Document alias Lexical.RemoteControl alias Lexical.RemoteControl.Api + alias Lexical.RemoteControl.Commands alias Lexical.RemoteControl.Dispatch.Handlers.Indexing alias Lexical.RemoteControl.Search @@ -20,6 +21,7 @@ defmodule Lexical.RemoteControl.Dispatch.Handlers.IndexingTest do update_index = &Search.Indexer.update_index/2 start_supervised!(RemoteControl.Dispatch) + start_supervised!(Commands.Reindex) start_supervised!({Search.Store, [project, create_index, update_index]}) start_supervised!(Lexical.Server.Application.document_store_child_spec()) diff --git a/apps/server/lib/lexical/server/project/search_listener.ex b/apps/server/lib/lexical/server/project/search_listener.ex new file mode 100644 index 000000000..2af99b770 --- /dev/null +++ b/apps/server/lib/lexical/server/project/search_listener.ex @@ -0,0 +1,55 @@ +defmodule Lexical.Server.Project.SearchListener do + alias Lexical.Formats + alias Lexical.Project + alias Lexical.Protocol.Id + alias Lexical.Protocol.Requests + alias Lexical.RemoteControl.Api + alias Lexical.Server + alias Lexical.Server.Window + + import Api.Messages + + use GenServer + require Logger + + def start_link(%Project{} = project) do + GenServer.start_link(__MODULE__, [project], name: name(project)) + end + + defp name(%Project{} = project) do + :"#{Project.name(project)}::search_listener" + end + + @impl GenServer + def init([%Project{} = project]) do + Api.register_listener(project, self(), [ + project_reindex_requested(), + project_reindexed() + ]) + + {:ok, project} + end + + @impl GenServer + def handle_info(project_reindex_requested(), %Project{} = project) do + Logger.info("project reindex requested") + send_code_lens_refresh() + + {:noreply, project} + end + + def handle_info(project_reindexed(elapsed_ms: elapsed), %Project{} = project) do + message = "Reindexed #{Project.name(project)} in #{Formats.time(elapsed, unit: :millisecond)}" + Logger.info(message) + send_code_lens_refresh() + + Window.show_info_message(message) + + {:noreply, project} + end + + defp send_code_lens_refresh do + request = Requests.CodeLensRefresh.new(id: Id.next()) + Server.server_request(request) + end +end diff --git a/apps/server/lib/lexical/server/project/supervisor.ex b/apps/server/lib/lexical/server/project/supervisor.ex index 1693c6281..58adf99bc 100644 --- a/apps/server/lib/lexical/server/project/supervisor.ex +++ b/apps/server/lib/lexical/server/project/supervisor.ex @@ -5,6 +5,7 @@ defmodule Lexical.Server.Project.Supervisor do alias Lexical.Server.Project.Intelligence alias Lexical.Server.Project.Node alias Lexical.Server.Project.Progress + alias Lexical.Server.Project.SearchListener use Supervisor @@ -26,7 +27,8 @@ defmodule Lexical.Server.Project.Supervisor do {ProjectNodeSupervisor, project}, {Node, project}, {Diagnostics, project}, - {Intelligence, project} + {Intelligence, project}, + {SearchListener, project} ] Supervisor.init(children, strategy: :one_for_one) diff --git a/apps/server/lib/lexical/server/provider/handlers.ex b/apps/server/lib/lexical/server/provider/handlers.ex index fa39c6da0..7c524e0ad 100644 --- a/apps/server/lib/lexical/server/provider/handlers.ex +++ b/apps/server/lib/lexical/server/provider/handlers.ex @@ -13,6 +13,9 @@ defmodule Lexical.Server.Provider.Handlers do %Requests.CodeAction{} -> {:ok, Handlers.CodeAction} + %Requests.CodeLens{} -> + {:ok, Handlers.CodeLens} + %Requests.Completion{} -> {:ok, Handlers.Completion} @@ -22,6 +25,9 @@ defmodule Lexical.Server.Provider.Handlers do %Requests.Hover{} -> {:ok, Handlers.Hover} + %Requests.ExecuteCommand{} -> + {:ok, Handlers.Commands} + %request_module{} -> {:error, {:unhandled, request_module}} end diff --git a/apps/server/lib/lexical/server/provider/handlers/code_lens.ex b/apps/server/lib/lexical/server/provider/handlers/code_lens.ex new file mode 100644 index 000000000..83527eff9 --- /dev/null +++ b/apps/server/lib/lexical/server/provider/handlers/code_lens.ex @@ -0,0 +1,59 @@ +defmodule Lexical.Server.Provider.Handlers.CodeLens do + alias Lexical.Document + alias Lexical.Document.Position + alias Lexical.Document.Range + alias Lexical.Features + alias Lexical.Project + alias Lexical.Protocol.Requests + alias Lexical.Protocol.Responses + alias Lexical.Protocol.Types.CodeLens + alias Lexical.RemoteControl + alias Lexical.Server.Provider.Env + alias Lexical.Server.Provider.Handlers + + import Document.Line + require Logger + + def handle(%Requests.CodeLens{} = request, %Env{} = env) do + lenses = + case reindex_lens(env.project, request.document) do + nil -> [] + lens -> List.wrap(lens) + end + + response = Responses.CodeLens.new(request.id, lenses) + {:reply, response} + end + + defp reindex_lens(%Project{} = project, %Document{} = document) do + if show_reindex_lens?(project, document) do + range = def_project_range(document) + command = Handlers.Commands.reindex_command(project) + + CodeLens.new(command: command, range: range) + end + end + + @project_regex ~r/def\s+project\s/ + defp def_project_range(%Document{} = document) do + # returns the line in mix.exs where `def project` occurs + Enum.reduce_while(document.lines, nil, fn + line(text: line_text, line_number: line_number), _ -> + if String.match?(line_text, @project_regex) do + start_pos = Position.new(document, line_number, 1) + end_pos = Position.new(document, line_number, String.length(line_text)) + range = Range.new(start_pos, end_pos) + {:halt, range} + else + {:cont, nil} + end + end) + end + + defp show_reindex_lens?(%Project{} = project, %Document{} = document) do + document_path = Path.expand(document.path) + + Features.indexing_enabled?() and document_path == Project.mix_exs_path(project) and + not RemoteControl.Api.index_running?(project) + end +end diff --git a/apps/server/lib/lexical/server/provider/handlers/commands.ex b/apps/server/lib/lexical/server/provider/handlers/commands.ex new file mode 100644 index 000000000..6d608e4ad --- /dev/null +++ b/apps/server/lib/lexical/server/provider/handlers/commands.ex @@ -0,0 +1,60 @@ +defmodule Lexical.Server.Provider.Handlers.Commands do + alias Lexical.Project + alias Lexical.Protocol.Requests + alias Lexical.Protocol.Responses + alias Lexical.Protocol.Types + alias Lexical.Protocol.Types.ErrorCodes + alias Lexical.RemoteControl + alias Lexical.Server.Provider.Env + alias Lexical.Server.Window + + require ErrorCodes + require Logger + + @reindex_name "Reindex" + + def names do + [@reindex_name] + end + + def reindex_command(%Project{} = project) do + project_name = Project.name(project) + + Types.Command.new( + title: "Rebuild #{project_name}'s code search index", + command: @reindex_name + ) + end + + def handle(%Requests.ExecuteCommand{} = request, %Env{} = env) do + response = + case request.command do + @reindex_name -> + Logger.info("Reindex #{Project.name(env.project)}") + reindex(env.project, request.id) + + invalid -> + message = "#{invalid} is not a valid command" + internal_error(request.id, message) + end + + {:reply, response} + end + + defp reindex(%Project{} = project, request_id) do + case RemoteControl.Api.reindex(project) do + :ok -> + Responses.ExecuteCommand.new(request_id, "ok") + + error -> + Window.show_error_message("Indexing #{Project.name(project)} failed") + Logger.error("Indexing command failed due to #{inspect(error)}") + + internal_error(request_id, "Could not reindex: #{error}") + end + end + + defp internal_error(request_id, message) do + Responses.ExecuteCommand.error(request_id, :internal_error, message) + end +end diff --git a/apps/server/lib/lexical/server/state.ex b/apps/server/lib/lexical/server/state.ex index 1263e253d..5ce7744dc 100644 --- a/apps/server/lib/lexical/server/state.ex +++ b/apps/server/lib/lexical/server/state.ex @@ -18,6 +18,7 @@ defmodule Lexical.Server.State do alias Lexical.Protocol.Types.CodeAction alias Lexical.Protocol.Types.Completion alias Lexical.Protocol.Types.DidChangeWatchedFiles + alias Lexical.Protocol.Types.ExecuteCommand alias Lexical.Protocol.Types.FileEvent alias Lexical.Protocol.Types.FileSystemWatcher alias Lexical.Protocol.Types.Registration @@ -27,6 +28,7 @@ defmodule Lexical.Server.State do alias Lexical.Server.CodeIntelligence alias Lexical.Server.Configuration alias Lexical.Server.Project + alias Lexical.Server.Provider.Handlers alias Lexical.Server.Transport require CodeAction.Kind @@ -269,15 +271,19 @@ defmodule Lexical.Server.State do code_action_options = CodeAction.Options.new(code_action_kinds: @supported_code_actions, resolve_provider: false) + command_options = ExecuteCommand.Registration.Options.new(commands: Handlers.Commands.names()) + completion_options = Completion.Options.new(trigger_characters: CodeIntelligence.Completion.trigger_characters()) server_capabilities = Types.ServerCapabilities.new( code_action_provider: code_action_options, + code_lens_provider: true, completion_provider: completion_options, definition_provider: true, document_formatting_provider: true, + execute_command_provider: command_options, hover_provider: true, references_provider: Features.indexing_enabled?(), text_document_sync: sync_options diff --git a/apps/server/lib/lexical/server/window.ex b/apps/server/lib/lexical/server/window.ex index be9d655a3..37a06a8f6 100644 --- a/apps/server/lib/lexical/server/window.ex +++ b/apps/server/lib/lexical/server/window.ex @@ -7,13 +7,14 @@ defmodule Lexical.Server.Window do alias Lexical.Server.Transport @type level :: :error | :warning | :info | :log - - @levels [:error, :warning, :info, :log] - @type message_result :: {:errory, term()} | {:ok, nil} | {:ok, Types.Message.ActionItem.t()} @type on_response_callback :: (message_result() -> any()) + @type message :: String.t() + @type action :: String.t() - @spec log(level, String.t()) :: :ok + @levels [:error, :warning, :info, :log] + + @spec log(level, message()) :: :ok def log(level, message) when level in @levels and is_binary(message) do log_message = apply(LogMessage, level, [message]) Transport.write(log_message) @@ -26,19 +27,36 @@ defmodule Lexical.Server.Window do end end - @spec show(level, String.t()) :: :ok + @spec show(level(), message()) :: :ok def show(level, message) when level in @levels and is_binary(message) do show_message = apply(ShowMessage, level, [message]) Transport.write(show_message) :ok end - @spec show_message(String.t(), level()) :: :ok - def show_message(message, message_type) do - request = Requests.ShowMessageRequest.new(id: Id.next(), message: message, type: message_type) + @spec show_message(level(), message()) :: :ok + def show_message(level, message) do + request = Requests.ShowMessageRequest.new(id: Id.next(), message: message, type: level) Lexical.Server.server_request(request) end + for level <- @levels, + fn_name = :"show_#{level}_message" do + def unquote(fn_name)(message) do + show_message(unquote(level), message) + end + end + + for level <- @levels, + fn_name = :"show_#{level}_message" do + @doc """ + Shows a message at the #{level} level. Delegates to `show_message/4` + """ + def unquote(fn_name)(message, actions, on_response) when is_function(on_response, 1) do + show_message(unquote(level), message, actions, on_response) + end + end + @doc """ Shows a message request and handles the response @@ -50,8 +68,8 @@ defmodule Lexical.Server.Window do The strings passed in as the `actions` command are displayed to the user, and when they select one, the `Types.Message.ActionItem` is passed to the callback function. """ - @spec show_message(String.t(), level(), [String.t()], on_response_callback) :: :ok - def show_message(message, message_type, actions, on_response) + @spec show_message(level(), message(), [action()], on_response_callback) :: :ok + def show_message(level, message, actions, on_response) when is_function(on_response, 1) do action_items = Enum.map(actions, fn action_string -> @@ -63,7 +81,7 @@ defmodule Lexical.Server.Window do id: Id.next(), message: message, actions: action_items, - type: message_type + type: level ) Lexical.Server.server_request(request, fn _request, response -> on_response.(response) end) diff --git a/apps/server/test/lexical/server/provider/handlers/code_lens_test.exs b/apps/server/test/lexical/server/provider/handlers/code_lens_test.exs new file mode 100644 index 000000000..f9e1a37b9 --- /dev/null +++ b/apps/server/test/lexical/server/provider/handlers/code_lens_test.exs @@ -0,0 +1,99 @@ +defmodule Lexical.Server.Provider.Handlers.CodeLensTest do + alias Lexical.Document + alias Lexical.Project + alias Lexical.Proto.Convert + alias Lexical.Protocol.Requests.CodeLens + alias Lexical.Protocol.Types + alias Lexical.RemoteControl + alias Lexical.Server + alias Lexical.Server.Provider.Env + alias Lexical.Server.Provider.Handlers + + import Lexical.Test.Protocol.Fixtures.LspProtocol + import Lexical.RemoteControl.Api.Messages + import Lexical.Test.Fixtures + import Lexical.Test.RangeSupport + + use ExUnit.Case, async: false + use Patch + + setup_all do + start_supervised(Document.Store) + project = project(:umbrella) + + {:ok, _} = start_supervised({DynamicSupervisor, Server.Project.Supervisor.options()}) + + {:ok, _} = start_supervised({Server.Project.Supervisor, project}) + + RemoteControl.Api.register_listener(project, self(), [project_compiled()]) + RemoteControl.Api.schedule_compile(project, true) + + assert_receive project_compiled(), 5000 + + {:ok, project: project} + end + + defp with_indexing_enabled(_) do + patch(Lexical.Features, :indexing_enabled?, true) + :ok + end + + defp with_mix_exs(%{project: project}) do + path = Project.mix_exs_path(project) + %{uri: Document.Path.ensure_uri(path)} + end + + def build_request(path) do + uri = Document.Path.ensure_uri(path) + + params = [ + text_document: [uri: uri] + ] + + with {:ok, _} <- Document.Store.open_temporary(uri), + {:ok, req} <- build(CodeLens, params) do + Convert.to_native(req) + end + end + + def handle(request, project) do + Handlers.CodeLens.handle(request, %Env{project: project}) + end + + describe "code lens for mix.exs" do + setup [:with_mix_exs, :with_indexing_enabled] + + test "emits a code lens at the project definition", %{project: project, uri: referenced_uri} do + mix_exs_path = Document.Path.ensure_path(referenced_uri) + mix_exs = File.read!(mix_exs_path) + + {:ok, request} = build_request(mix_exs_path) + {:reply, %{result: lenses}} = handle(request, project) + + assert [%Types.CodeLens{} = code_lens] = lenses + + assert extract(mix_exs, code_lens.range) =~ "def project" + assert code_lens.command == Handlers.Commands.reindex_command(project) + end + + test "does not emit a code lens for a project file", %{project: project} do + {:ok, request} = + project + |> Project.project_path() + |> Path.join("apps/first/lib/umbrella/first.ex") + |> build_request() + + assert {:reply, %{result: []}} = handle(request, project) + end + + test "does not emite a code lens for an umbrella app's mix.exs", %{project: project} do + {:ok, request} = + project + |> Project.project_path() + |> Path.join("apps/first/mix.exs") + |> build_request() + + assert {:reply, %{result: []}} = handle(request, project) + end + end +end