Skip to content

Commit 1204313

Browse files
authored
feat: workspace folders (#18)
This adds support for multiple projects within the server node, and starts a project node for each detected project within the workspace. The detection implemented here is just using the presence of a `mix.exs` file to assume it's a project root. It also changes the manager node to use the name of the workspace folder instead of the name of the first project that starts distribution. The reason is that if you have projects `a` and `b` in the same root folder `foo`, and you start Expert on the root folder, then the manager node will be named after the first project node that is started. Running `epmd -names` shows this: - `project-a-entropy` - `project-b-entropy` - `manager-a-entropy` I htink having the manager be named `manager-foo-entropy` sounds more correct. To test this branch you can clone this repo: https://github.com/doorgan/monorepo_test Then check on files from both `main` and `secondary` that: - Completions work - Go to definition and workspace symbols work - Code actions (like Refactorex refactors) work - Instructing the LSP to reindex everything runs the indexer on all projects Additionally you can also test that Lexical works on `.exs` or `.ex` files in a workspace without a `mix.exs` file. It is worth noting that this should be tested with VS Code using the Lexical LSP extension and pointing it to an expert release using this branch, or some other editor (like emacs) that doesn't automatically start a new Expert server(ie something other than Neovim) Closes #136
1 parent e1e5666 commit 1204313

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+1574
-174
lines changed

apps/engine/benchmarks/ast_analyze.exs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
Mix.install([:benchee])
2+
13
alias Forge.Ast
24
alias Forge.Document
35

apps/engine/benchmarks/enum_index.exs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
Mix.install([:benchee])
2+
13
alias Engine.Search.Indexer
24

35
path =

apps/engine/benchmarks/ets_bench.exs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
Mix.install([:benchee])
2+
13
alias Forge.Project
24

35
alias Engine.Search.Store.Backends.Ets

apps/engine/benchmarks/versions_bench.exs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
Mix.install([:benchee])
2+
13
alias Forge.VM.Versions
24

35
Benchee.run(%{

apps/engine/mix.exs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ defmodule Engine.MixProject do
4444

4545
defp deps do
4646
[
47-
{:benchee, "~> 1.3", only: :test},
4847
{:deps_nix, "~> 2.4", only: :dev},
4948
Mix.Credo.dependency(),
5049
Mix.Dialyzer.dependency(),

apps/expert/config/runtime.exs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,3 @@ if Code.ensure_loaded?(LoggerFileBackend) do
2020
else
2121
:ok
2222
end
23-
24-
require Logger

apps/expert/lib/expert.ex

Lines changed: 97 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
defmodule Expert do
2+
alias Expert.ActiveProjects
23
alias Expert.Project
34
alias Expert.Protocol.Convert
45
alias Expert.Protocol.Id
56
alias Expert.Provider.Handlers
67
alias Expert.State
8+
alias Forge.Project
79
alias GenLSP.Enumerations
810
alias GenLSP.Requests
911
alias GenLSP.Structures
@@ -16,6 +18,7 @@ defmodule Expert do
1618
GenLSP.Notifications.TextDocumentDidChange,
1719
GenLSP.Notifications.WorkspaceDidChangeConfiguration,
1820
GenLSP.Notifications.WorkspaceDidChangeWatchedFiles,
21+
GenLSP.Notifications.WorkspaceDidChangeWorkspaceFolders,
1922
GenLSP.Notifications.TextDocumentDidClose,
2023
GenLSP.Notifications.TextDocumentDidOpen,
2124
GenLSP.Notifications.TextDocumentDidSave,
@@ -54,17 +57,28 @@ defmodule Expert do
5457

5558
with {:ok, response, state} <- State.initialize(state, request),
5659
{:ok, response} <- Expert.Protocol.Convert.to_lsp(response) do
57-
Task.Supervisor.start_child(:expert_task_queue, fn ->
58-
# dirty sleep to allow initialize response to return before progress reports
59-
Process.sleep(50)
60-
config = state.configuration
60+
workspace_folders = request.params.workspace_folders || []
6161

62-
log_info(lsp, "Starting project")
62+
projects =
63+
for %{uri: uri} <- workspace_folders,
64+
project = Project.new(uri),
65+
# Only include Mix projects, or include single-folder workspaces with
66+
# bare elixir files.
67+
project.mix_project? || Project.elixir_project?(project) do
68+
project
69+
end
6370

64-
start_result = Project.Supervisor.start(config.project)
71+
ActiveProjects.set_projects(projects)
6572

66-
send(Expert, {:engine_initialized, start_result})
67-
end)
73+
for project <- projects do
74+
Task.Supervisor.start_child(:expert_task_queue, fn ->
75+
log_info(lsp, project, "Starting project")
76+
77+
start_result = Expert.Project.Supervisor.ensure_node_started(project)
78+
79+
send(Expert, {:engine_initialized, project, start_result})
80+
end)
81+
end
6882

6983
{:reply, response, assign(lsp, state: state)}
7084
else
@@ -109,43 +123,74 @@ defmodule Expert do
109123
def handle_request(request, lsp) do
110124
state = assigns(lsp).state
111125

112-
if state.engine_initialized? do
113-
with {:ok, handler} <- fetch_handler(request),
114-
{:ok, request} <- Convert.to_native(request),
115-
{:ok, response} <- handler.handle(request, state.configuration),
116-
{:ok, response} <- Expert.Protocol.Convert.to_lsp(response) do
117-
{:reply, response, lsp}
118-
else
119-
{:error, {:unhandled, _}} ->
120-
Logger.info("Unhandled request: #{request.method}")
121-
122-
{:reply,
123-
%GenLSP.ErrorResponse{
124-
code: GenLSP.Enumerations.ErrorCodes.method_not_found(),
125-
message: "Method not found"
126-
}, lsp}
127-
128-
error ->
129-
message = "Failed to handle #{request.method}, #{inspect(error)}"
130-
Logger.error(message)
131-
132-
{:reply,
133-
%GenLSP.ErrorResponse{
134-
code: GenLSP.Enumerations.ErrorCodes.internal_error(),
135-
message: message
136-
}, lsp}
137-
end
126+
with {:ok, handler} <- fetch_handler(request),
127+
{:ok, request} <- Convert.to_native(request),
128+
:ok <- check_engine_initialized(request),
129+
{:ok, response} <- handler.handle(request, state.configuration),
130+
{:ok, response} <- Expert.Protocol.Convert.to_lsp(response) do
131+
{:reply, response, lsp}
138132
else
139-
GenLSP.warning(
140-
lsp,
141-
"Received request #{request.method} before engine was initialized. Ignoring."
142-
)
133+
{:error, {:unhandled, _}} ->
134+
Logger.info("Unhandled request: #{request.method}")
143135

144-
{:noreply, lsp}
136+
{:reply,
137+
%GenLSP.ErrorResponse{
138+
code: GenLSP.Enumerations.ErrorCodes.method_not_found(),
139+
message: "Method not found"
140+
}, lsp}
141+
142+
{:error, :engine_not_initialized, project} ->
143+
GenLSP.info(
144+
lsp,
145+
"Received request #{request.method} before engine for #{project && Project.name(project)} was initialized. Ignoring."
146+
)
147+
148+
{:reply, nil, lsp}
149+
150+
error ->
151+
message = "Failed to handle #{request.method}, #{inspect(error)}"
152+
Logger.error(message)
153+
154+
{:reply,
155+
%GenLSP.ErrorResponse{
156+
code: GenLSP.Enumerations.ErrorCodes.internal_error(),
157+
message: message
158+
}, lsp}
145159
end
146160
end
147161

162+
defp check_engine_initialized(request) do
163+
if document_request?(request) do
164+
case Forge.Document.Container.context_document(request, nil) do
165+
%Forge.Document{} = document ->
166+
projects = ActiveProjects.projects()
167+
project = Project.project_for_document(projects, document)
168+
169+
if ActiveProjects.active?(project) do
170+
:ok
171+
else
172+
{:error, :engine_not_initialized, project}
173+
end
174+
175+
nil ->
176+
{:error, :engine_not_initialized, nil}
177+
end
178+
else
179+
:ok
180+
end
181+
end
182+
183+
defp document_request?(%{document: %Forge.Document{}}), do: true
184+
185+
defp document_request?(%{params: params}) do
186+
document_request?(params)
187+
end
188+
189+
defp document_request?(%{text_document: %{uri: _}}), do: true
190+
defp document_request?(_), do: false
191+
148192
def handle_notification(%GenLSP.Notifications.Initialized{}, lsp) do
193+
Logger.info("Server initialized, registering capabilities")
149194
registrations = registrations()
150195

151196
if nil != GenLSP.request(lsp, registrations) do
@@ -189,35 +234,33 @@ defmodule Expert do
189234
end
190235
end
191236

192-
def handle_info({:engine_initialized, {:ok, _pid}}, lsp) do
193-
state = assigns(lsp).state
194-
195-
new_state = %{state | engine_initialized?: true}
196-
197-
lsp = assign(lsp, state: new_state)
198-
199-
Logger.info("Engine initialized")
237+
def handle_info({:engine_initialized, project, {:ok, _pid}}, lsp) do
238+
log_info(
239+
lsp,
240+
project,
241+
"Engine initialized for project #{Project.name(project)}"
242+
)
200243

201244
{:noreply, lsp}
202245
end
203246

204-
def handle_info({:engine_initialized, {:error, reason}}, lsp) do
247+
def handle_info({:engine_initialized, project, {:error, reason}}, lsp) do
205248
error_message = initialization_error_message(reason)
206-
log_error(lsp, error_message)
249+
log_error(lsp, project, error_message)
207250

208251
{:noreply, lsp}
209252
end
210253

211-
def log_info(lsp \\ get_lsp(), message) do
212-
message = log_prepend_project_root(message, assigns(lsp).state)
254+
def log_info(lsp \\ get_lsp(), project, message) do
255+
message = log_prepend_project_root(message, project)
213256

214257
Logger.info(message)
215258
GenLSP.info(lsp, message)
216259
end
217260

218261
# When logging errors we also notify the client to display the message
219-
def log_error(lsp \\ get_lsp(), message) do
220-
message = log_prepend_project_root(message, assigns(lsp).state)
262+
def log_error(lsp \\ get_lsp(), project, message) do
263+
message = log_prepend_project_root(message, project)
221264

222265
Logger.error(message)
223266
GenLSP.error(lsp, message)
@@ -335,11 +378,7 @@ defmodule Expert do
335378
end
336379
end
337380

338-
defp log_prepend_project_root(message, %State{
339-
configuration: %Expert.Configuration{project: %Forge.Project{} = project}
340-
}) do
381+
defp log_prepend_project_root(message, project) do
341382
"[Project #{project.root_uri}] #{message}"
342383
end
343-
344-
defp log_prepend_project_root(message, _state), do: message
345384
end
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
defmodule Expert.ActiveProjects do
2+
@moduledoc """
3+
A cache to keep track of active projects.
4+
5+
Since GenLSP events happen asynchronously, we use an ets table to keep track of
6+
them and avoid race conditions when we try to update the list of active projects.
7+
"""
8+
alias Forge.Project
9+
10+
use GenServer
11+
12+
def child_spec(_) do
13+
%{
14+
id: __MODULE__,
15+
start: {__MODULE__, :start_link, []}
16+
}
17+
end
18+
19+
def start_link do
20+
GenServer.start_link(__MODULE__, [], name: __MODULE__)
21+
end
22+
23+
def init(_) do
24+
__MODULE__ = :ets.new(__MODULE__, [:set, :named_table, :public, read_concurrency: true])
25+
26+
__MODULE__.Ready =
27+
:ets.new(__MODULE__.Ready, [:set, :named_table, :public, read_concurrency: true])
28+
29+
{:ok, nil}
30+
end
31+
32+
def projects do
33+
__MODULE__
34+
|> :ets.tab2list()
35+
|> Enum.map(fn {_, project} -> project end)
36+
end
37+
38+
@spec find_by_root_uri(Forge.uri()) :: Project.t() | nil
39+
def find_by_root_uri(root_uri) do
40+
case :ets.lookup(__MODULE__, root_uri) do
41+
[{_, project}] -> project
42+
[] -> nil
43+
end
44+
end
45+
46+
def add_projects(new_projects) when is_list(new_projects) do
47+
for new_project <- new_projects do
48+
# We use `:ets.insert_new/2` to avoid overwriting the cached project's entropy
49+
:ets.insert_new(__MODULE__, {new_project.root_uri, new_project})
50+
end
51+
end
52+
53+
def remove_projects(removed_projects) when is_list(removed_projects) do
54+
for removed_project <- removed_projects do
55+
:ets.delete(__MODULE__, removed_project.root_uri)
56+
end
57+
end
58+
59+
def set_projects(new_projects) when is_list(new_projects) do
60+
:ets.delete_all_objects(__MODULE__)
61+
add_projects(new_projects)
62+
end
63+
64+
def set_ready(%Project{} = project, ready?) when is_boolean(ready?) do
65+
if ready? do
66+
:ets.insert(__MODULE__.Ready, {project.root_uri, true})
67+
else
68+
:ets.delete(__MODULE__.Ready, project.root_uri)
69+
end
70+
end
71+
72+
def active?(%Project{} = project) do
73+
case :ets.lookup(__MODULE__.Ready, project.root_uri) do
74+
[{_, true}] -> true
75+
_ -> false
76+
end
77+
end
78+
end

apps/expert/lib/expert/application.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ defmodule Expert.Application do
117117
{GenLSP.Assigns, [name: Expert.Assigns]},
118118
{Task.Supervisor, name: :expert_task_queue},
119119
{GenLSP.Buffer, [name: Expert.Buffer] ++ buffer_opts},
120+
{Expert.ActiveProjects, []},
120121
{Expert,
121122
name: Expert,
122123
buffer: Expert.Buffer,

apps/expert/lib/expert/configuration.ex

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,16 @@ defmodule Expert.Configuration do
66
alias Expert.Configuration.Support
77
alias Expert.Dialyzer
88
alias Expert.Protocol.Id
9-
alias Forge.Project
109
alias GenLSP.Notifications.WorkspaceDidChangeConfiguration
1110
alias GenLSP.Requests
1211
alias GenLSP.Structures
1312

14-
defstruct project: nil,
15-
support: nil,
13+
defstruct support: nil,
1614
client_name: nil,
1715
additional_watched_extensions: nil,
1816
dialyzer_enabled?: false
1917

2018
@type t :: %__MODULE__{
21-
project: Project.t() | nil,
2219
support: support | nil,
2320
client_name: String.t() | nil,
2421
additional_watched_extensions: [String.t()] | nil,
@@ -29,12 +26,11 @@ defmodule Expert.Configuration do
2926

3027
@dialyzer {:nowarn_function, set_dialyzer_enabled: 2}
3128

32-
@spec new(Forge.uri(), map(), String.t() | nil) :: t
33-
def new(root_uri, %Structures.ClientCapabilities{} = client_capabilities, client_name) do
29+
@spec new(Structures.ClientCapabilities.t(), String.t() | nil) :: t
30+
def new(%Structures.ClientCapabilities{} = client_capabilities, client_name) do
3431
support = Support.new(client_capabilities)
35-
project = Project.new(root_uri)
3632

37-
%__MODULE__{support: support, project: project, client_name: client_name}
33+
%__MODULE__{support: support, client_name: client_name}
3834
|> tap(&set/1)
3935
end
4036

@@ -44,7 +40,6 @@ defmodule Expert.Configuration do
4440
end
4541

4642
defp set(%__MODULE__{} = config) do
47-
# FIXME(mhanberg): I don't think this will work once we have workspace support
4843
:persistent_term.put(__MODULE__, config)
4944
end
5045

0 commit comments

Comments
 (0)