Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 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
e0688f4
Merge remote-tracking branch 'origin/main' into doorgan/multiroot_sup…
doorgan Dec 19, 2025
ccacb82
fix: restore bare ex files support
doorgan Dec 19, 2025
ad43e5e
fix: remove debug log
doorgan Dec 19, 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
153 changes: 97 additions & 56 deletions apps/expert/lib/expert.ex
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
defmodule Expert do
alias Expert.ActiveProjects
alias Expert.Project
alias Expert.Protocol.Convert
alias Expert.Protocol.Id
alias Expert.Provider.Handlers
alias Expert.State
alias Forge.Project
alias GenLSP.Enumerations
alias GenLSP.Requests
alias GenLSP.Structures
Expand All @@ -16,6 +18,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 @@ -56,15 +59,28 @@ defmodule Expert do

with {:ok, response, state} <- State.initialize(state, request),
{:ok, response} <- Expert.Protocol.Convert.to_lsp(response) do
Task.Supervisor.start_child(:expert_task_queue, fn ->
config = state.configuration
workspace_folders = request.params.workspace_folders || []

log_info(lsp, "Starting project")
projects =
for %{uri: uri} <- workspace_folders,
project = Project.new(uri),
# Only include Mix projects, or include single-folder workspaces with
# bare elixir files.
project.mix_project? || length(workspace_folders) == 1 do
project
end

start_result = Project.Supervisor.start(config.project)
ActiveProjects.set_projects(projects)

send(Expert, {:engine_initialized, start_result})
end)
for project <- projects do
Task.Supervisor.start_child(:expert_task_queue, fn ->
log_info(lsp, project, "Starting project")

start_result = Expert.Project.Supervisor.ensure_node_started(project)

send(Expert, {:engine_initialized, project, start_result})
end)
end

{:reply, response, assign(lsp, state: state)}
else
Expand Down Expand Up @@ -109,43 +125,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 @@ -189,35 +236,33 @@ defmodule Expert do
end
end

def handle_info({:engine_initialized, {:ok, _pid}}, lsp) do
state = assigns(lsp).state

new_state = %{state | engine_initialized?: true}

lsp = assign(lsp, state: new_state)

Logger.info("Engine initialized")
def handle_info({:engine_initialized, project, {:ok, _pid}}, lsp) do
log_info(
lsp,
project,
"Engine initialized for project #{Project.name(project)}"
)

{:noreply, lsp}
end

def handle_info({:engine_initialized, {:error, reason}}, lsp) do
def handle_info({:engine_initialized, project, {:error, reason}}, lsp) do
error_message = initialization_error_message(reason)
log_error(lsp, error_message)
log_error(lsp, project, error_message)

{:noreply, lsp}
end

def log_info(lsp \\ get_lsp(), message) do
message = log_prepend_project_root(message, assigns(lsp).state)
def log_info(lsp \\ get_lsp(), project, message) do
message = log_prepend_project_root(message, project)

Logger.info(message)
GenLSP.info(lsp, message)
end

# When logging errors we also notify the client to display the message
def log_error(lsp \\ get_lsp(), message) do
message = log_prepend_project_root(message, assigns(lsp).state)
def log_error(lsp \\ get_lsp(), project, message) do
message = log_prepend_project_root(message, project)

Logger.error(message)
GenLSP.error(lsp, message)
Expand Down Expand Up @@ -332,11 +377,7 @@ defmodule Expert do
end
end

defp log_prepend_project_root(message, %State{
configuration: %Expert.Configuration{project: %Forge.Project{} = project}
}) do
defp log_prepend_project_root(message, project) do
"[Project #{project.root_uri}] #{message}"
end

defp log_prepend_project_root(message, _state), do: message
end
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 @@ -85,6 +85,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
13 changes: 4 additions & 9 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 All @@ -44,7 +40,6 @@ defmodule Expert.Configuration do
end

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

Expand Down
4 changes: 2 additions & 2 deletions apps/expert/lib/expert/engine_node.ex
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,11 @@ defmodule Expert.EngineNode do

case node_start do
{:ok, _} ->
unquote(port_mapper).register()
:ok = unquote(port_mapper).register()
IO.puts("ok")

{:error, reason} ->
IO.puts("error starting node:\n \#{inspect(reason)}")
IO.puts("error starting node:\n #{inspect(reason)}")
end
end

Expand Down
Loading
Loading