Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
83c901b
Support multiple root projects
doorgan May 26, 2025
3ba3c91
Use saner parent_path? implementation and add tests
doorgan May 28, 2025
63ceee1
Merge main
doorgan Jun 20, 2025
3582869
Start projects dynamically
doorgan Jun 20, 2025
c0c879a
Fix credo issue
doorgan Jun 21, 2025
74a24fb
Merge remote-tracking branch 'origin/main' into doorgan/multiroot_sup…
doorgan Jul 4, 2025
c0aa69b
Merge remote-tracking branch 'origin/main' into doorgan/multiroot_sup…
doorgan Jul 4, 2025
cf379a5
chore: use lsp workspace folder functionality
doorgan Jul 5, 2025
b4b54a8
fix: support the case where the root folder contains subprojects
doorgan Jul 5, 2025
5f32c6a
Merge remote-tracking branch 'origin/main' into doorgan/multiroot_sup…
doorgan Jul 9, 2025
90ab70a
chore: add tests
doorgan Jul 9, 2025
d6279f4
fix: use GenLSP logging utility
doorgan Jul 9, 2025
e25b98b
chore: remove leftover code
doorgan Jul 9, 2025
962b351
Merge remote-tracking branch 'origin/main' into doorgan/multiroot_sup…
doorgan Jul 10, 2025
3fa0ea4
chore: remove tests from new fixture projects
doorgan Jul 14, 2025
a4370d5
fix: prevent tests from randomly not running
doorgan Jul 15, 2025
88e884b
fix: fix flaky test
doorgan Jul 15, 2025
9aa0ca7
Merge remote-tracking branch 'origin/main' into doorgan/multiroot_sup…
doorgan Jul 29, 2025
3b1dfba
Merge remote-tracking branch 'origin/main' into doorgan/multiroot_sup…
doorgan Aug 21, 2025
51ebda9
Merge remote-tracking branch 'origin/main' into doorgan/multiroot_sup…
doorgan Aug 21, 2025
1cf3bd9
fix: fallback to heuristics if workspace_folders is not set
doorgan Aug 21, 2025
04968a1
fix: remove heuristics and just default to no workspace folders
doorgan Aug 21, 2025
7e342cb
fix: try to start projects in a task
doorgan Aug 21, 2025
a3201f2
chore: format
doorgan Aug 21, 2025
4c812a6
Merge remote-tracking branch 'origin/main' into doorgan/multiroot_sup…
doorgan Aug 21, 2025
1092e9f
Merge remote-tracking branch 'origin/main' into doorgan/multiroot_sup…
doorgan Aug 22, 2025
e32c101
Merge remote-tracking branch 'origin/main' into doorgan/multiroot_sup…
doorgan Aug 26, 2025
ba96dc2
Merge remote-tracking branch 'origin/main' into doorgan/multiroot_sup…
doorgan Nov 28, 2025
f2acc37
fix: merge envs for engine node
doorgan Nov 28, 2025
5ac219a
fix: unused var warning
doorgan Nov 28, 2025
1a6a12e
fix: ignore requests until the relevant engine is started
doorgan Dec 10, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/engine/benchmarks/ast_analyze.exs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
Mix.install([:benchee])

alias Forge.Ast
alias Forge.Document

Expand Down
2 changes: 2 additions & 0 deletions apps/engine/benchmarks/enum_index.exs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
Mix.install([:benchee])

alias Engine.Search.Indexer

path =
Expand Down
2 changes: 2 additions & 0 deletions apps/engine/benchmarks/ets_bench.exs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
Mix.install([:benchee])

alias Forge.Project

alias Engine.Search.Store.Backends.Ets
Expand Down
2 changes: 2 additions & 0 deletions apps/engine/benchmarks/versions_bench.exs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
Mix.install([:benchee])

alias Forge.VM.Versions

Benchee.run(%{
Expand Down
1 change: 0 additions & 1 deletion apps/engine/mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ defmodule Engine.MixProject do

defp deps do
[
{:benchee, "~> 1.3", only: :test},
{:deps_nix, "~> 2.4", only: :dev},
Mix.Credo.dependency(),
Mix.Dialyzer.dependency(),
Expand Down
2 changes: 0 additions & 2 deletions apps/expert/config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,3 @@ if Code.ensure_loaded?(LoggerFileBackend) do
else
:ok
end

require Logger
108 changes: 65 additions & 43 deletions apps/expert/lib/expert.ex
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
defmodule Expert do
alias Expert.ActiveProjects
alias Expert.Protocol.Convert
alias Expert.Protocol.Id
alias Expert.Provider.Handlers
alias Expert.State
alias Forge.Project
alias GenLSP.Requests
alias GenLSP.Structures

Expand All @@ -14,6 +16,7 @@ defmodule Expert do
GenLSP.Notifications.TextDocumentDidChange,
GenLSP.Notifications.WorkspaceDidChangeConfiguration,
GenLSP.Notifications.WorkspaceDidChangeWatchedFiles,
GenLSP.Notifications.WorkspaceDidChangeWorkspaceFolders,
GenLSP.Notifications.TextDocumentDidClose,
GenLSP.Notifications.TextDocumentDidOpen,
GenLSP.Notifications.TextDocumentDidSave,
Expand Down Expand Up @@ -93,43 +96,74 @@ defmodule Expert do
def handle_request(request, lsp) do
state = assigns(lsp).state

if state.engine_initialized? do
with {:ok, handler} <- fetch_handler(request),
{:ok, request} <- Convert.to_native(request),
{:ok, response} <- handler.handle(request, state.configuration),
{:ok, response} <- Expert.Protocol.Convert.to_lsp(response) do
{:reply, response, lsp}
else
{:error, {:unhandled, _}} ->
Logger.info("Unhandled request: #{request.method}")

{:reply,
%GenLSP.ErrorResponse{
code: GenLSP.Enumerations.ErrorCodes.method_not_found(),
message: "Method not found"
}, lsp}

error ->
message = "Failed to handle #{request.method}, #{inspect(error)}"
Logger.error(message)

{:reply,
%GenLSP.ErrorResponse{
code: GenLSP.Enumerations.ErrorCodes.internal_error(),
message: message
}, lsp}
end
with {:ok, handler} <- fetch_handler(request),
{:ok, request} <- Convert.to_native(request),
:ok <- check_engine_initialized(request),
{:ok, response} <- handler.handle(request, state.configuration),
{:ok, response} <- Expert.Protocol.Convert.to_lsp(response) do
{:reply, response, lsp}
else
GenLSP.warning(
lsp,
"Received request #{request.method} before engine was initialized. Ignoring."
)
{:error, {:unhandled, _}} ->
Logger.info("Unhandled request: #{request.method}")

{:noreply, lsp}
{:reply,
%GenLSP.ErrorResponse{
code: GenLSP.Enumerations.ErrorCodes.method_not_found(),
message: "Method not found"
}, lsp}

{:error, :engine_not_initialized, project} ->
GenLSP.info(
lsp,
"Received request #{request.method} before engine for #{project && Project.name(project)} was initialized. Ignoring."
)

{:reply, nil, lsp}

error ->
message = "Failed to handle #{request.method}, #{inspect(error)}"
Logger.error(message)

{:reply,
%GenLSP.ErrorResponse{
code: GenLSP.Enumerations.ErrorCodes.internal_error(),
message: message
}, lsp}
end
end

defp check_engine_initialized(request) do
if document_request?(request) do
case Forge.Document.Container.context_document(request, nil) do
%Forge.Document{} = document ->
projects = ActiveProjects.projects()
project = Project.project_for_document(projects, document)

if ActiveProjects.active?(project) do
:ok
else
{:error, :engine_not_initialized, project}
end

nil ->
{:error, :engine_not_initialized, nil}
end
else
:ok
end
end

defp document_request?(%{document: %Forge.Document{}}), do: true

defp document_request?(%{params: params}) do
document_request?(params)
end

defp document_request?(%{text_document: %{uri: _}}), do: true
defp document_request?(_), do: false

def handle_notification(%GenLSP.Notifications.Initialized{}, lsp) do
Logger.info("Server initialized, registering capabilities")
registrations = registrations()

if nil != GenLSP.request(lsp, registrations) do
Expand Down Expand Up @@ -173,18 +207,6 @@ defmodule Expert do
end
end

def handle_info(:engine_initialized, lsp) do
state = assigns(lsp).state

new_state = %{state | engine_initialized?: true}

lsp = assign(lsp, state: new_state)

Logger.info("Engine initialized")

{:noreply, lsp}
end

def handle_info(:default_config, lsp) do
state = assigns(lsp).state

Expand Down
70 changes: 70 additions & 0 deletions apps/expert/lib/expert/active_projects.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
defmodule Expert.ActiveProjects do
@moduledoc """
A cache to keep track of active projects.

Since GenLSP events happen asynchronously, we use an ets table to keep track of
them and avoid race conditions when we try to update the list of active projects.
"""
alias Forge.Project

use GenServer

def child_spec(_) do
%{
id: __MODULE__,
start: {__MODULE__, :start_link, []}
}
end

def start_link do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end

def init(_) do
__MODULE__ = :ets.new(__MODULE__, [:set, :named_table, :public, read_concurrency: true])

__MODULE__.Ready =
:ets.new(__MODULE__.Ready, [:set, :named_table, :public, read_concurrency: true])

{:ok, nil}
end

def projects do
__MODULE__
|> :ets.tab2list()
|> Enum.map(fn {_, project} -> project end)
end

def add_projects(new_projects) when is_list(new_projects) do
for new_project <- new_projects do
# We use `:ets.insert_new/2` to avoid overwriting the cached project's entropy
:ets.insert_new(__MODULE__, {new_project.root_uri, new_project})
end
end

def remove_projects(removed_projects) when is_list(removed_projects) do
for removed_project <- removed_projects do
:ets.delete(__MODULE__, removed_project.root_uri)
end
end

def set_projects(new_projects) when is_list(new_projects) do
:ets.delete_all_objects(__MODULE__)
add_projects(new_projects)
end

def set_ready(%Project{} = project, ready?) when is_boolean(ready?) do
if ready? do
:ets.insert(__MODULE__.Ready, {project.root_uri, true})
else
:ets.delete(__MODULE__.Ready, project.root_uri)
end
end

def active?(%Project{} = project) do
case :ets.lookup(__MODULE__.Ready, project.root_uri) do
[{_, true}] -> true
_ -> false
end
end
end
1 change: 1 addition & 0 deletions apps/expert/lib/expert/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ defmodule Expert.Application do
{GenLSP.Assigns, [name: Expert.Assigns]},
{Task.Supervisor, name: :expert_task_queue},
{GenLSP.Buffer, [name: Expert.Buffer] ++ buffer_opts},
{Expert.ActiveProjects, []},
{Expert,
name: Expert,
buffer: Expert.Buffer,
Expand Down
12 changes: 4 additions & 8 deletions apps/expert/lib/expert/configuration.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,16 @@ defmodule Expert.Configuration do
alias Expert.Configuration.Support
alias Expert.Dialyzer
alias Expert.Protocol.Id
alias Forge.Project
alias GenLSP.Notifications.WorkspaceDidChangeConfiguration
alias GenLSP.Requests
alias GenLSP.Structures

defstruct project: nil,
support: nil,
defstruct support: nil,
client_name: nil,
additional_watched_extensions: nil,
dialyzer_enabled?: false

@type t :: %__MODULE__{
project: Project.t() | nil,
support: support | nil,
client_name: String.t() | nil,
additional_watched_extensions: [String.t()] | nil,
Expand All @@ -29,12 +26,11 @@ defmodule Expert.Configuration do

@dialyzer {:nowarn_function, set_dialyzer_enabled: 2}

@spec new(Forge.uri(), map(), String.t() | nil) :: t
def new(root_uri, %Structures.ClientCapabilities{} = client_capabilities, client_name) do
@spec new(Structures.ClientCapabilities.t(), String.t() | nil) :: t
def new(%Structures.ClientCapabilities{} = client_capabilities, client_name) do
support = Support.new(client_capabilities)
project = Project.new(root_uri)

%__MODULE__{support: support, project: project, client_name: client_name}
%__MODULE__{support: support, client_name: client_name}
|> tap(&set/1)
end

Expand Down
8 changes: 6 additions & 2 deletions apps/expert/lib/expert/engine_supervisor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@ defmodule Expert.EngineSupervisor do
end

def start_link(%Project{} = project) do
DynamicSupervisor.start_link(__MODULE__, project, name: __MODULE__, strategy: :one_for_one)
DynamicSupervisor.start_link(__MODULE__, project, name: name(project), strategy: :one_for_one)
end

defp name(%Project{} = project) do
:"#{Project.name(project)}::project_node_supervisor"
end

def start_project_node(%Project{} = project) do
DynamicSupervisor.start_child(__MODULE__, EngineNode.child_spec(project))
DynamicSupervisor.start_child(name(project), EngineNode.child_spec(project))
end

@impl true
Expand Down
4 changes: 3 additions & 1 deletion apps/expert/lib/expert/port.ex
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ defmodule Expert.Port do
opts =
opts
|> Keyword.put_new_lazy(:cd, fn -> Project.root_path(project) end)
|> Keyword.put_new(:env, environment_variables)
|> Keyword.update(:env, environment_variables, fn old_env ->
old_env ++ environment_variables
end)

open(project, elixir_executable, opts)
end
Expand Down
12 changes: 1 addition & 11 deletions apps/expert/lib/expert/project/supervisor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,6 @@ defmodule Expert.Project.Supervisor do
alias Expert.Project.SearchListener
alias Forge.Project

# TODO: this module is slightly weird
# it is a module based supervisor, but has lots of dynamic supervisor functions
# what I learned is that in Expert.Application, it is starting an ad hoc
# dynamic supervisor, calling a function from this module
# Later, when the server is initializing, it calls the start function in
# this module, which starts a normal supervisor, which the start_link and
# init callbacks will be called
# my suggestion is to separate the dynamic supervisor functionalities from
# this module into its own module

use Supervisor

def start_link(%Project{} = project) do
Expand Down Expand Up @@ -49,7 +39,7 @@ defmodule Expert.Project.Supervisor do
DynamicSupervisor.terminate_child(Expert.Project.DynamicSupervisor.name(), pid)
end

defp name(%Project{} = project) do
def name(%Project{} = project) do
:"#{Project.name(project)}::supervisor"
end
end
8 changes: 6 additions & 2 deletions apps/expert/lib/expert/provider/handlers/code_action.ex
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
defmodule Expert.Provider.Handlers.CodeAction do
alias Expert.ActiveProjects
alias Expert.Configuration
alias Expert.EngineApi
alias Forge.CodeAction
alias Forge.Project
alias GenLSP.Requests
alias GenLSP.Structures

def handle(
%Requests.TextDocumentCodeAction{params: %Structures.CodeActionParams{} = params},
%Configuration{} = config
%Configuration{}
) do
document = Forge.Document.Container.context_document(params, nil)
projects = ActiveProjects.projects()
project = Project.project_for_document(projects, document)
diagnostics = Enum.map(params.context.diagnostics, &to_code_action_diagnostic/1)

code_actions =
EngineApi.code_actions(
config.project,
project,
document,
params.range,
diagnostics,
Expand Down
Loading
Loading