From 83c901bc9788264d95f3eae77a5f63b7eddcdcfd Mon Sep 17 00:00:00 2001 From: doorgan Date: Mon, 26 May 2025 17:47:02 -0300 Subject: [PATCH 01/17] Support multiple root projects This adds support for multiple projects within the server node, and starts a project node for each detected project within the workspace. 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. --- apps/engine/lib/engine/engine.ex | 10 +++++- .../engine/engine/project_node_supervisor.ex | 8 +++-- apps/expert/lib/expert/configuration.ex | 36 ++++++++++++++++--- .../lib/expert/provider/handlers/code_lens.ex | 4 ++- .../lib/expert/provider/handlers/commands.ex | 30 ++++++++++------ .../lib/expert/provider/handlers/hover.ex | 6 ++-- apps/expert/lib/expert/state.ex | 28 ++++++++++----- apps/expert/mix.exs | 2 +- .../provider/handlers/code_lens_test.exs | 2 +- .../handlers/find_references_test.exs | 2 +- .../handlers/go_to_definition_test.exs | 2 +- .../expert/provider/handlers/hover_test.exs | 2 +- apps/expert/test/expert/task_queue_test.exs | 2 +- apps/forge/lib/forge/path.ex | 35 ++++++++++++++++++ apps/forge/lib/forge/project.ex | 20 +++++++++++ apps/forge/lib/forge/workspace.ex | 27 ++++++++++++++ 16 files changed, 181 insertions(+), 35 deletions(-) create mode 100644 apps/forge/lib/forge/path.ex create mode 100644 apps/forge/lib/forge/workspace.ex diff --git a/apps/engine/lib/engine/engine.ex b/apps/engine/lib/engine/engine.ex index ac8a3585..5ff1fe52 100644 --- a/apps/engine/lib/engine/engine.ex +++ b/apps/engine/lib/engine/engine.ex @@ -119,7 +119,15 @@ defmodule Engine do end def manager_node_name(%Project{} = project) do - :"manager-#{Project.name(project)}-#{Project.entropy(project)}@127.0.0.1" + workspace = Lexical.Workspace.get_workspace() + + workspace_name = + case workspace do + nil -> Project.name(project) + _ -> Lexical.Workspace.name(workspace) + end + + :"manager-#{workspace_name}-#{Project.entropy(project)}@127.0.0.1" end defp start_net_kernel(%Project{} = project) do diff --git a/apps/engine/lib/engine/engine/project_node_supervisor.ex b/apps/engine/lib/engine/engine/project_node_supervisor.ex index cd7d3d13..dd758ef3 100644 --- a/apps/engine/lib/engine/engine/project_node_supervisor.ex +++ b/apps/engine/lib/engine/engine/project_node_supervisor.ex @@ -14,11 +14,15 @@ defmodule Engine.ProjectNodeSupervisor 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__, ProjectNode.child_spec(project)) + DynamicSupervisor.start_child(name(project), ProjectNode.child_spec(project)) end @impl true diff --git a/apps/expert/lib/expert/configuration.ex b/apps/expert/lib/expert/configuration.ex index 38961642..1833e4c8 100644 --- a/apps/expert/lib/expert/configuration.ex +++ b/apps/expert/lib/expert/configuration.ex @@ -11,14 +11,14 @@ defmodule Expert.Configuration do alias GenLSP.Requests alias GenLSP.Structures - defstruct project: nil, + defstruct projects: [], support: nil, client_name: nil, additional_watched_extensions: nil, dialyzer_enabled?: false @type t :: %__MODULE__{ - project: Project.t() | nil, + projects: [Project.t()], support: support | nil, client_name: String.t() | nil, additional_watched_extensions: [String.t()] | nil, @@ -32,9 +32,9 @@ defmodule Expert.Configuration do @spec new(Forge.uri(), map(), String.t() | nil) :: t def new(root_uri, %Structures.ClientCapabilities{} = client_capabilities, client_name) do support = Support.new(client_capabilities) - project = Project.new(root_uri) + projects = find_projects(root_uri) - %__MODULE__{support: support, project: project, client_name: client_name} + %__MODULE__{support: support, projects: projects, client_name: client_name} |> tap(&set/1) end @@ -43,6 +43,34 @@ defmodule Expert.Configuration do struct!(__MODULE__, [support: Support.new()] ++ attrs) end + defp find_projects(root_uri) do + root_path = Lexical.Document.Path.from_uri(root_uri) + root_mix_exs = Path.join(root_path, "mix.exs") + + projects = + if File.exists?(root_mix_exs) do + [Project.new(root_uri)] + else + find_multiroot_projects(root_path) + end + + if projects == [], do: [Project.new(root_uri)], else: projects + end + + defp find_multiroot_projects(root_path) do + mix_exs_blob = Path.join([root_path, "**", "mix.exs"]) + + for mix_exs_path <- Path.wildcard(mix_exs_blob), + "deps" not in Path.split(mix_exs_path) do + project_uri = + mix_exs_path + |> Path.dirname() + |> Lexical.Document.Path.to_uri() + + Project.new(project_uri) + end + end + defp set(%__MODULE__{} = config) do :persistent_term.put(__MODULE__, config) end diff --git a/apps/expert/lib/expert/provider/handlers/code_lens.ex b/apps/expert/lib/expert/provider/handlers/code_lens.ex index 38650e8f..47996ee1 100644 --- a/apps/expert/lib/expert/provider/handlers/code_lens.ex +++ b/apps/expert/lib/expert/provider/handlers/code_lens.ex @@ -16,10 +16,12 @@ defmodule Expert.Provider.Handlers.CodeLens do %Requests.TextDocumentCodeLens{params: %Structures.CodeLensParams{} = params} = request, %Configuration{} = config ) do + project = Project.project_for_document(config.projects, request.document) + document = Document.Container.context_document(params, nil) lenses = - case reindex_lens(config.project, document) do + case reindex_lens(project, request.document) do nil -> [] lens -> List.wrap(lens) end diff --git a/apps/expert/lib/expert/provider/handlers/commands.ex b/apps/expert/lib/expert/provider/handlers/commands.ex index 8e37c3ba..9b62f328 100644 --- a/apps/expert/lib/expert/provider/handlers/commands.ex +++ b/apps/expert/lib/expert/provider/handlers/commands.ex @@ -33,8 +33,9 @@ defmodule Expert.Provider.Handlers.Commands do response = case params.command do @reindex_name -> - Logger.info("Reindex #{Project.name(config.project)}") - reindex(config.project, request.id) + project_names = Enum.map_join(config.projects, ", ", &Project.name/1) + Logger.info("Reindex #{project_names}") + reindex_all(config.projects, request.id) invalid -> message = "#{invalid} is not a valid command" @@ -44,16 +45,25 @@ defmodule Expert.Provider.Handlers.Commands do {:reply, response} end - defp reindex(%Project{} = project, request_id) do - case Engine.Api.reindex(project) do - :ok -> - %Response{id: request_id, result: "ok"} + defp reindex_all(projects, request_id) do + result = + Enum.reduce_while(projects, :ok, fn project, _ -> + case Engine.Api.reindex(project) do + :ok -> + {:cont, :ok} - error -> - Window.show_error_message("Indexing #{Project.name(project)} failed") - Logger.error("Indexing command failed due to #{inspect(error)}") + 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}") + {:halt, internal_error(request_id, "Could not reindex: #{error}")} + end + end) + + if result == :ok do + %Response{id: request_id, result: "ok"} + else + result end end diff --git a/apps/expert/lib/expert/provider/handlers/hover.ex b/apps/expert/lib/expert/provider/handlers/hover.ex index 0d040ee6..e4d5ccff 100644 --- a/apps/expert/lib/expert/provider/handlers/hover.ex +++ b/apps/expert/lib/expert/provider/handlers/hover.ex @@ -21,11 +21,13 @@ defmodule Expert.Provider.Handlers.Hover do ) do document = Document.Container.context_document(params, nil) + project = Project.project_for_document(config.projects, request.document) + maybe_hover = with {:ok, _document, %Ast.Analysis{} = analysis} <- Document.Store.fetch(document.uri, :analysis), - {:ok, entity, range} <- resolve_entity(config.project, analysis, params.position), - {:ok, markdown} <- hover_content(entity, config.project) do + {:ok, entity, range} <- resolve_entity(project, analysis, params.position), + {:ok, markdown} <- hover_content(entity, project) do content = Markdown.to_content(markdown) %Structures.Hover{contents: content, range: range} else diff --git a/apps/expert/lib/expert/state.ex b/apps/expert/lib/expert/state.ex index ca3926f6..38c314fe 100644 --- a/apps/expert/lib/expert/state.ex +++ b/apps/expert/lib/expert/state.ex @@ -47,9 +47,14 @@ defmodule Expert.State do _ -> nil end + root_path = Lexical.Document.Path.from_uri(event.root_uri) + + root_path + |> Lexical.Workspace.new() + |> Lexical.Workspace.set_workspace() + config = Configuration.new(event.root_uri, event.capabilities, client_name) new_state = %__MODULE__{state | configuration: config, initialized?: true} - Logger.info("Starting project at uri #{config.project.root_uri}") event_id |> initialize_result() @@ -60,7 +65,12 @@ defmodule Expert.State do Transport.write(registrations()) - Project.Supervisor.start(config.project) + for project <- config.projects do + Logger.info("Starting project at uri #{project.root_uri}") + result = Project.Supervisor.start(project) + Logger.info("result: #{inspect(result)}") + end + {:ok, new_state} end @@ -137,11 +147,11 @@ defmodule Expert.State do def apply(%__MODULE__{} = state, %Notifications.TextDocumentDidChange{params: event}) do uri = event.text_document.uri version = event.text_document.version - project = state.configuration.project + project = Forge.Project.project_for_uri(state.configuration.projects, uri) case Document.Store.get_and_update( uri, - &Document.apply_content_changes(&1, version, event.content_changes) + &Document.apply_content_changes(&1, version, event.lsp.content_changes) ) do {:ok, updated_source} -> updated_message = @@ -153,7 +163,7 @@ defmodule Expert.State do ) Api.broadcast(project, updated_message) - Api.compile_document(state.configuration.project, updated_source) + Api.compile_document(project, updated_source) {:ok, state} error -> @@ -198,10 +208,11 @@ defmodule Expert.State do def apply(%__MODULE__{} = state, %Notifications.TextDocumentDidSave{params: event}) do uri = event.text_document.uri + project = Lexical.Project.project_for_uri(state.configuration.projects, uri) case Document.Store.save(uri) do :ok -> - Api.schedule_compile(state.configuration.project, false) + Api.schedule_compile(project, false) {:ok, state} error -> @@ -223,9 +234,8 @@ defmodule Expert.State do end def apply(%__MODULE__{} = state, %Notifications.WorkspaceDidChangeWatchedFiles{params: event}) do - project = state.configuration.project - - Enum.each(event.changes, fn %Structures.FileEvent{} = change -> + for project <- state.configuration.projects, + change <- event.changes do event = filesystem_event(project: Project, uri: change.uri, event_type: change.type) Engine.Api.broadcast(project, event) end) diff --git a/apps/expert/mix.exs b/apps/expert/mix.exs index 73fe08d2..d99aac34 100644 --- a/apps/expert/mix.exs +++ b/apps/expert/mix.exs @@ -17,7 +17,7 @@ defmodule Expert.MixProject do def application do [ - extra_applications: [:logger, :runtime_tools, :kernel, :erts], + extra_applications: [:logger, :runtime_tools, :kernel, :erts, :observer], mod: {Expert.Application, []} ] end diff --git a/apps/expert/test/expert/provider/handlers/code_lens_test.exs b/apps/expert/test/expert/provider/handlers/code_lens_test.exs index df300b6c..ee1fae61 100644 --- a/apps/expert/test/expert/provider/handlers/code_lens_test.exs +++ b/apps/expert/test/expert/provider/handlers/code_lens_test.exs @@ -56,7 +56,7 @@ defmodule Expert.Provider.Handlers.CodeLensTest do end def handle(request, project) do - config = Expert.Configuration.new(project: project) + config = Expert.Configuration.new(projects: [project]) Handlers.CodeLens.handle(request, config) end diff --git a/apps/expert/test/expert/provider/handlers/find_references_test.exs b/apps/expert/test/expert/provider/handlers/find_references_test.exs index aace5b6a..ce5b73c0 100644 --- a/apps/expert/test/expert/provider/handlers/find_references_test.exs +++ b/apps/expert/test/expert/provider/handlers/find_references_test.exs @@ -45,7 +45,7 @@ defmodule Expert.Provider.Handlers.FindReferencesTest do end def handle(request, project) do - config = Expert.Configuration.new(project: project) + config = Expert.Configuration.new(projects: [project]) Handlers.FindReferences.handle(request, config) end diff --git a/apps/expert/test/expert/provider/handlers/go_to_definition_test.exs b/apps/expert/test/expert/provider/handlers/go_to_definition_test.exs index 407b1fca..efa861d0 100644 --- a/apps/expert/test/expert/provider/handlers/go_to_definition_test.exs +++ b/apps/expert/test/expert/provider/handlers/go_to_definition_test.exs @@ -52,7 +52,7 @@ defmodule Expert.Provider.Handlers.GoToDefinitionTest do end def handle(request, project) do - config = Expert.Configuration.new(project: project) + config = Expert.Configuration.new(projects: [project]) Handlers.GoToDefinition.handle(request, config) end diff --git a/apps/expert/test/expert/provider/handlers/hover_test.exs b/apps/expert/test/expert/provider/handlers/hover_test.exs index 03c22256..ce8bac78 100644 --- a/apps/expert/test/expert/provider/handlers/hover_test.exs +++ b/apps/expert/test/expert/provider/handlers/hover_test.exs @@ -721,7 +721,7 @@ defmodule Expert.Provider.Handlers.HoverTest do with {position, hovered} <- pop_cursor(hovered), {:ok, document} <- document_with_content(project, hovered), {:ok, request} <- hover_request(document.uri, position) do - config = Expert.Configuration.new(project: project) + config = Expert.Configuration.new(projects: [project]) Handlers.Hover.handle(request, config) end end diff --git a/apps/expert/test/expert/task_queue_test.exs b/apps/expert/test/expert/task_queue_test.exs index 89e0e566..b123a2ad 100644 --- a/apps/expert/test/expert/task_queue_test.exs +++ b/apps/expert/test/expert/task_queue_test.exs @@ -14,7 +14,7 @@ defmodule Expert.TaskQueueTest do use Forge.Test.EventualAssertions setup_all do - {:ok, config: Configuration.new(project: Fixtures.project())} + {:ok, config: Configuration.new(projects: [Fixtures.project()])} end setup do diff --git a/apps/forge/lib/forge/path.ex b/apps/forge/lib/forge/path.ex new file mode 100644 index 00000000..f8a69796 --- /dev/null +++ b/apps/forge/lib/forge/path.ex @@ -0,0 +1,35 @@ +defmodule Forge.Path do + @moduledoc """ + Helpers for working with paths. + """ + + @doc """ + Checks if the `parent_path` is a parent directory of the `child_path`. + + This function normalizes both paths and compares their segments to determine + if the `parent_path` is a prefix of the `child_path`. + + ## Examples + + iex> Forge.Path.parent_path?("/home/user/docs/file.txt", "/home/user") + true + + iex> Forge.Path.parent_path?("/home/user/docs/file.txt", "/home/admin") + false + + iex> Forge.Path.parent_path?("/home/user/docs", "/home/user/docs") + true + + iex> Forge.Path.parent_path?("/home/user/docs", "/home/user/docs/subdir") + false + """ + def parent_path?(child_path, parent_path) do + normalized_child = Path.expand(child_path) + normalized_parent = Path.expand(parent_path) + + child_segments = Path.split(normalized_child) + parent_segments = Path.split(normalized_parent) + + Enum.take(child_segments, length(parent_segments)) == parent_segments + end +end diff --git a/apps/forge/lib/forge/project.ex b/apps/forge/lib/forge/project.ex index ad612ab5..b4ac81ec 100644 --- a/apps/forge/lib/forge/project.ex +++ b/apps/forge/lib/forge/project.ex @@ -321,4 +321,24 @@ defmodule Forge.Project do |> root_path() |> Path.basename() end + + @doc """ + Finds the project that contains the given path. + """ + def project_for_uri(projects, uri) do + path = Document.Path.from_uri(uri) + + Enum.find(projects, fn project -> + Lexical.Path.parent_path?(path, root_path(project)) + end) + end + + @doc """ + Finds the project that contains the given document. + """ + def project_for_document(projects, %Document{} = document) do + Enum.find(projects, fn project -> + Lexical.Path.parent_path?(document.path, root_path(project)) + end) + end end diff --git a/apps/forge/lib/forge/workspace.ex b/apps/forge/lib/forge/workspace.ex new file mode 100644 index 00000000..5660aef6 --- /dev/null +++ b/apps/forge/lib/forge/workspace.ex @@ -0,0 +1,27 @@ +defmodule Forge.Workspace do + @moduledoc """ + The representation of the root directory where the server is running. + """ + + defstruct [:root_path] + + @type t :: %__MODULE__{ + root_path: String.t() | nil + } + + def new(root_path) do + %__MODULE__{root_path: root_path} + end + + def name(workspace) do + Path.basename(workspace.root_path) + end + + def set_workspace(workspace) do + :persistent_term.put({__MODULE__, :workspace}, workspace) + end + + def get_workspace do + :persistent_term.get({__MODULE__, :workspace}, nil) + end +end From 3ba3c91cb9912596f055acf08e321004b473796d Mon Sep 17 00:00:00 2001 From: doorgan Date: Wed, 28 May 2025 16:21:18 -0300 Subject: [PATCH 02/17] Use saner parent_path? implementation and add tests --- apps/forge/lib/forge/path.ex | 12 +++++------- apps/forge/test/forge/path_test.exs | 5 +++++ 2 files changed, 10 insertions(+), 7 deletions(-) create mode 100644 apps/forge/test/forge/path_test.exs diff --git a/apps/forge/lib/forge/path.ex b/apps/forge/lib/forge/path.ex index f8a69796..f815dd38 100644 --- a/apps/forge/lib/forge/path.ex +++ b/apps/forge/lib/forge/path.ex @@ -6,9 +6,6 @@ defmodule Forge.Path do @doc """ Checks if the `parent_path` is a parent directory of the `child_path`. - This function normalizes both paths and compares their segments to determine - if the `parent_path` is a prefix of the `child_path`. - ## Examples iex> Forge.Path.parent_path?("/home/user/docs/file.txt", "/home/user") @@ -23,13 +20,14 @@ defmodule Forge.Path do iex> Forge.Path.parent_path?("/home/user/docs", "/home/user/docs/subdir") false """ + def parent_path?(child_path, parent_path) when byte_size(child_path) < byte_size(parent_path) do + false + end + def parent_path?(child_path, parent_path) do normalized_child = Path.expand(child_path) normalized_parent = Path.expand(parent_path) - child_segments = Path.split(normalized_child) - parent_segments = Path.split(normalized_parent) - - Enum.take(child_segments, length(parent_segments)) == parent_segments + String.starts_with?(normalized_child, normalized_parent) end end diff --git a/apps/forge/test/forge/path_test.exs b/apps/forge/test/forge/path_test.exs new file mode 100644 index 00000000..98db4c5d --- /dev/null +++ b/apps/forge/test/forge/path_test.exs @@ -0,0 +1,5 @@ +defmodule Forge.PathTest do + use ExUnit.Case, async: true + + doctest Forge.Path +end From 63ceee10180105d91d20625257709f6415b2426f Mon Sep 17 00:00:00 2001 From: doorgan Date: Fri, 20 Jun 2025 18:36:57 -0300 Subject: [PATCH 03/17] Merge main --- apps/engine/lib/engine/engine.ex | 4 ++-- apps/expert/lib/expert.ex | 2 -- apps/expert/lib/expert/configuration.ex | 4 ++-- .../expert/provider/handlers/code_action.ex | 4 +++- .../lib/expert/provider/handlers/code_lens.ex | 5 +++-- .../lib/expert/provider/handlers/completion.ex | 4 +++- .../provider/handlers/document_symbols.ex | 4 +++- .../provider/handlers/find_references.ex | 4 +++- .../lib/expert/provider/handlers/formatting.ex | 4 +++- .../provider/handlers/go_to_definition.ex | 4 +++- .../lib/expert/provider/handlers/hover.ex | 3 +-- .../provider/handlers/workspace_symbol.ex | 18 ++++++++++++++---- apps/expert/lib/expert/state.ex | 17 +++++++++-------- apps/forge/lib/forge/document/container.ex | 11 +++++++++++ apps/forge/lib/forge/project.ex | 4 ++-- 15 files changed, 62 insertions(+), 30 deletions(-) diff --git a/apps/engine/lib/engine/engine.ex b/apps/engine/lib/engine/engine.ex index 5ff1fe52..8345452d 100644 --- a/apps/engine/lib/engine/engine.ex +++ b/apps/engine/lib/engine/engine.ex @@ -119,12 +119,12 @@ defmodule Engine do end def manager_node_name(%Project{} = project) do - workspace = Lexical.Workspace.get_workspace() + workspace = Forge.Workspace.get_workspace() workspace_name = case workspace do nil -> Project.name(project) - _ -> Lexical.Workspace.name(workspace) + _ -> Forge.Workspace.name(workspace) end :"manager-#{workspace_name}-#{Project.entropy(project)}@127.0.0.1" diff --git a/apps/expert/lib/expert.ex b/apps/expert/lib/expert.ex index 83989e36..0cbbafd0 100644 --- a/apps/expert/lib/expert.ex +++ b/apps/expert/lib/expert.ex @@ -127,8 +127,6 @@ defmodule Expert do def handle_message(%_{} = request, %State{} = state) do with {:ok, handler} <- fetch_handler(request), {:ok, request} <- Convert.to_native(request) do - # Logger.info("Handling request: #{inspect(request, pretty: true)}") - TaskQueue.add(request.id, {handler, :handle, [request, state.configuration]}) else {:error, {:unhandled, _}} -> diff --git a/apps/expert/lib/expert/configuration.ex b/apps/expert/lib/expert/configuration.ex index 1833e4c8..0dd67345 100644 --- a/apps/expert/lib/expert/configuration.ex +++ b/apps/expert/lib/expert/configuration.ex @@ -44,7 +44,7 @@ defmodule Expert.Configuration do end defp find_projects(root_uri) do - root_path = Lexical.Document.Path.from_uri(root_uri) + root_path = Forge.Document.Path.from_uri(root_uri) root_mix_exs = Path.join(root_path, "mix.exs") projects = @@ -65,7 +65,7 @@ defmodule Expert.Configuration do project_uri = mix_exs_path |> Path.dirname() - |> Lexical.Document.Path.to_uri() + |> Forge.Document.Path.to_uri() Project.new(project_uri) end diff --git a/apps/expert/lib/expert/provider/handlers/code_action.ex b/apps/expert/lib/expert/provider/handlers/code_action.ex index be0c637b..b185da1b 100644 --- a/apps/expert/lib/expert/provider/handlers/code_action.ex +++ b/apps/expert/lib/expert/provider/handlers/code_action.ex @@ -1,6 +1,7 @@ defmodule Expert.Provider.Handlers.CodeAction do alias Engine.CodeAction alias Expert.Configuration + alias Forge.Project alias Forge.Protocol.Response alias GenLSP.Requests alias GenLSP.Structures @@ -11,11 +12,12 @@ defmodule Expert.Provider.Handlers.CodeAction do %Configuration{} = config ) do document = Forge.Document.Container.context_document(params, nil) + project = Project.project_for_document(config.projects, document) diagnostics = Enum.map(params.context.diagnostics, &to_code_action_diagnostic/1) code_actions = Engine.Api.code_actions( - config.project, + project, document, params.range, diagnostics, diff --git a/apps/expert/lib/expert/provider/handlers/code_lens.ex b/apps/expert/lib/expert/provider/handlers/code_lens.ex index 47996ee1..ba1951b8 100644 --- a/apps/expert/lib/expert/provider/handlers/code_lens.ex +++ b/apps/expert/lib/expert/provider/handlers/code_lens.ex @@ -16,12 +16,13 @@ defmodule Expert.Provider.Handlers.CodeLens do %Requests.TextDocumentCodeLens{params: %Structures.CodeLensParams{} = params} = request, %Configuration{} = config ) do - project = Project.project_for_document(config.projects, request.document) + document = Document.Container.context_document(params, nil) + project = Project.project_for_document(config.projects, document) document = Document.Container.context_document(params, nil) lenses = - case reindex_lens(project, request.document) do + case reindex_lens(project, document) do nil -> [] lens -> List.wrap(lens) end diff --git a/apps/expert/lib/expert/provider/handlers/completion.ex b/apps/expert/lib/expert/provider/handlers/completion.ex index b7d6402a..737c1593 100644 --- a/apps/expert/lib/expert/provider/handlers/completion.ex +++ b/apps/expert/lib/expert/provider/handlers/completion.ex @@ -4,6 +4,7 @@ defmodule Expert.Provider.Handlers.Completion do alias Forge.Ast alias Forge.Document alias Forge.Document.Position + alias Forge.Project alias Forge.Protocol.Response alias GenLSP.Enumerations.CompletionTriggerKind alias GenLSP.Requests @@ -19,10 +20,11 @@ defmodule Expert.Provider.Handlers.Completion do %Configuration{} = config ) do document = Document.Container.context_document(params, nil) + project = Project.project_for_document(config.projects, document) completions = CodeIntelligence.Completion.complete( - config.project, + project, document_analysis(document, params.position), params.position, params.context || %CompletionContext{trigger_kind: CompletionTriggerKind.invoked()} diff --git a/apps/expert/lib/expert/provider/handlers/document_symbols.ex b/apps/expert/lib/expert/provider/handlers/document_symbols.ex index ed2edd4f..8e14772f 100644 --- a/apps/expert/lib/expert/provider/handlers/document_symbols.ex +++ b/apps/expert/lib/expert/provider/handlers/document_symbols.ex @@ -3,6 +3,7 @@ defmodule Expert.Provider.Handlers.DocumentSymbols do alias Engine.CodeIntelligence.Symbols alias Expert.Configuration alias Forge.Document + alias Forge.Project alias Forge.Protocol.Response alias GenLSP.Enumerations.SymbolKind alias GenLSP.Requests @@ -10,9 +11,10 @@ defmodule Expert.Provider.Handlers.DocumentSymbols do def handle(%Requests.TextDocumentDocumentSymbol{} = request, %Configuration{} = config) do document = Document.Container.context_document(request.params, nil) + project = Project.project_for_document(config.projects, document) symbols = - config.project + project |> Api.document_symbols(document) |> Enum.map(&to_response(&1, document)) diff --git a/apps/expert/lib/expert/provider/handlers/find_references.ex b/apps/expert/lib/expert/provider/handlers/find_references.ex index 4f740247..214fef67 100644 --- a/apps/expert/lib/expert/provider/handlers/find_references.ex +++ b/apps/expert/lib/expert/provider/handlers/find_references.ex @@ -3,6 +3,7 @@ defmodule Expert.Provider.Handlers.FindReferences do alias Expert.Configuration alias Forge.Ast alias Forge.Document + alias Forge.Project alias Forge.Protocol.Response alias GenLSP.Requests.TextDocumentReferences alias GenLSP.Structures @@ -14,12 +15,13 @@ defmodule Expert.Provider.Handlers.FindReferences do %Configuration{} = config ) do document = Forge.Document.Container.context_document(params, nil) + project = Project.project_for_document(config.projects, document) include_declaration? = !!params.context.include_declaration locations = case Document.Store.fetch(document.uri, :analysis) do {:ok, _document, %Ast.Analysis{} = analysis} -> - Api.references(config.project, analysis, params.position, include_declaration?) + Api.references(project, analysis, params.position, include_declaration?) _ -> nil diff --git a/apps/expert/lib/expert/provider/handlers/formatting.ex b/apps/expert/lib/expert/provider/handlers/formatting.ex index 1ce544de..03ff7307 100644 --- a/apps/expert/lib/expert/provider/handlers/formatting.ex +++ b/apps/expert/lib/expert/provider/handlers/formatting.ex @@ -1,6 +1,7 @@ defmodule Expert.Provider.Handlers.Formatting do alias Expert.Configuration alias Forge.Document.Changes + alias Forge.Project alias Forge.Protocol.Response alias GenLSP.Requests alias GenLSP.Structures @@ -13,8 +14,9 @@ defmodule Expert.Provider.Handlers.Formatting do %Configuration{} = config ) do document = Forge.Document.Container.context_document(params, nil) + project = Project.project_for_document(config.projects, document) - case Engine.Api.format(config.project, document) do + case Engine.Api.format(project, document) do {:ok, %Changes{} = document_edits} -> response = %Response{id: request.id, result: document_edits} {:reply, response} diff --git a/apps/expert/lib/expert/provider/handlers/go_to_definition.ex b/apps/expert/lib/expert/provider/handlers/go_to_definition.ex index 963f9d51..22f3f902 100644 --- a/apps/expert/lib/expert/provider/handlers/go_to_definition.ex +++ b/apps/expert/lib/expert/provider/handlers/go_to_definition.ex @@ -1,5 +1,6 @@ defmodule Expert.Provider.Handlers.GoToDefinition do alias Expert.Configuration + alias Forge.Project alias Forge.Protocol.Response alias GenLSP.Requests alias GenLSP.Structures @@ -13,8 +14,9 @@ defmodule Expert.Provider.Handlers.GoToDefinition do %Configuration{} = config ) do document = Forge.Document.Container.context_document(params, nil) + project = Project.project_for_document(config.projects, document) - case Engine.Api.definition(config.project, document, params.position) do + case Engine.Api.definition(project, document, params.position) do {:ok, native_location} -> {:reply, %Response{id: request.id, result: native_location}} diff --git a/apps/expert/lib/expert/provider/handlers/hover.ex b/apps/expert/lib/expert/provider/handlers/hover.ex index e4d5ccff..710a2d67 100644 --- a/apps/expert/lib/expert/provider/handlers/hover.ex +++ b/apps/expert/lib/expert/provider/handlers/hover.ex @@ -20,8 +20,7 @@ defmodule Expert.Provider.Handlers.Hover do %Configuration{} = config ) do document = Document.Container.context_document(params, nil) - - project = Project.project_for_document(config.projects, request.document) + project = Project.project_for_document(config.projects, document) maybe_hover = with {:ok, _document, %Ast.Analysis{} = analysis} <- diff --git a/apps/expert/lib/expert/provider/handlers/workspace_symbol.ex b/apps/expert/lib/expert/provider/handlers/workspace_symbol.ex index d2fb4a3e..9725a458 100644 --- a/apps/expert/lib/expert/provider/handlers/workspace_symbol.ex +++ b/apps/expert/lib/expert/provider/handlers/workspace_symbol.ex @@ -2,6 +2,7 @@ defmodule Expert.Provider.Handlers.WorkspaceSymbol do alias Engine.Api alias Engine.CodeIntelligence.Symbols alias Expert.Configuration + alias Forge.Project alias Forge.Protocol.Response alias GenLSP.Enumerations.SymbolKind alias GenLSP.Requests @@ -15,10 +16,7 @@ defmodule Expert.Provider.Handlers.WorkspaceSymbol do ) do symbols = if String.length(params.query) > 1 do - config.project - |> Api.workspace_symbols(params.query) - |> tap(fn symbols -> Logger.info("syms #{inspect(Enum.take(symbols, 5))}") end) - |> Enum.map(&to_response/1) + Enum.flat_map(config.projects, &gather_symbols(&1, request)) else [] end @@ -30,6 +28,18 @@ defmodule Expert.Provider.Handlers.WorkspaceSymbol do {:reply, response} end + defp gather_symbols( + %Project{} = project, + %Requests.WorkspaceSymbol{ + params: %Structures.WorkspaceSymbolParams{} = params + } + ) do + project + |> Api.workspace_symbols(params.query) + |> tap(fn symbols -> Logger.info("syms #{inspect(Enum.take(symbols, 5))}") end) + |> Enum.map(&to_response/1) + end + def to_response(%Symbols.Workspace{} = root) do %Structures.WorkspaceSymbol{ kind: to_kind(root.type), diff --git a/apps/expert/lib/expert/state.ex b/apps/expert/lib/expert/state.ex index 38c314fe..a2c25dc0 100644 --- a/apps/expert/lib/expert/state.ex +++ b/apps/expert/lib/expert/state.ex @@ -6,6 +6,7 @@ defmodule Expert.State do alias Expert.Provider.Handlers alias Expert.Transport alias Forge.Document + alias Forge.Project alias Forge.Protocol.Id alias Forge.Protocol.Response alias GenLSP.Enumerations @@ -47,11 +48,11 @@ defmodule Expert.State do _ -> nil end - root_path = Lexical.Document.Path.from_uri(event.root_uri) + root_path = Forge.Document.Path.from_uri(event.root_uri) root_path - |> Lexical.Workspace.new() - |> Lexical.Workspace.set_workspace() + |> Forge.Workspace.new() + |> Forge.Workspace.set_workspace() config = Configuration.new(event.root_uri, event.capabilities, client_name) new_state = %__MODULE__{state | configuration: config, initialized?: true} @@ -67,7 +68,7 @@ defmodule Expert.State do for project <- config.projects do Logger.info("Starting project at uri #{project.root_uri}") - result = Project.Supervisor.start(project) + result = Expert.Project.Supervisor.start(project) Logger.info("result: #{inspect(result)}") end @@ -147,11 +148,11 @@ defmodule Expert.State do def apply(%__MODULE__{} = state, %Notifications.TextDocumentDidChange{params: event}) do uri = event.text_document.uri version = event.text_document.version - project = Forge.Project.project_for_uri(state.configuration.projects, uri) + project = Project.project_for_uri(state.configuration.projects, uri) case Document.Store.get_and_update( uri, - &Document.apply_content_changes(&1, version, event.lsp.content_changes) + &Document.apply_content_changes(&1, version, event.content_changes) ) do {:ok, updated_source} -> updated_message = @@ -208,7 +209,7 @@ defmodule Expert.State do def apply(%__MODULE__{} = state, %Notifications.TextDocumentDidSave{params: event}) do uri = event.text_document.uri - project = Lexical.Project.project_for_uri(state.configuration.projects, uri) + project = Forge.Project.project_for_uri(state.configuration.projects, uri) case Document.Store.save(uri) do :ok -> @@ -238,7 +239,7 @@ defmodule Expert.State do change <- event.changes do event = filesystem_event(project: Project, uri: change.uri, event_type: change.type) Engine.Api.broadcast(project, event) - end) + end {:ok, state} end diff --git a/apps/forge/lib/forge/document/container.ex b/apps/forge/lib/forge/document/container.ex index 1a9298ad..5aa96615 100644 --- a/apps/forge/lib/forge/document/container.ex +++ b/apps/forge/lib/forge/document/container.ex @@ -36,6 +36,10 @@ defimpl Forge.Document.Container, for: Any do context_document(lsp_request, parent_context_document) end + def context_document(%{params: params}, parent_context_document) do + context_document(params, parent_context_document) + end + def context_document(%{text_document: %{uri: uri}}, parent_context_document) do case Document.Store.fetch(uri) do {:ok, document} -> document @@ -43,6 +47,13 @@ defimpl Forge.Document.Container, for: Any do end end + def context_document(%{uri: uri}, parent_context_document) when is_binary(uri) do + case Document.Store.fetch(uri) do + {:ok, document} -> document + _ -> parent_context_document + end + end + def context_document(_, parent_context_document) do parent_context_document end diff --git a/apps/forge/lib/forge/project.ex b/apps/forge/lib/forge/project.ex index b4ac81ec..cf978b82 100644 --- a/apps/forge/lib/forge/project.ex +++ b/apps/forge/lib/forge/project.ex @@ -329,7 +329,7 @@ defmodule Forge.Project do path = Document.Path.from_uri(uri) Enum.find(projects, fn project -> - Lexical.Path.parent_path?(path, root_path(project)) + Forge.Path.parent_path?(path, root_path(project)) end) end @@ -338,7 +338,7 @@ defmodule Forge.Project do """ def project_for_document(projects, %Document{} = document) do Enum.find(projects, fn project -> - Lexical.Path.parent_path?(document.path, root_path(project)) + Forge.Path.parent_path?(document.path, root_path(project)) end) end end From 3582869869e723f51b6a572e714ee9d209c60784 Mon Sep 17 00:00:00 2001 From: doorgan Date: Fri, 20 Jun 2025 19:54:26 -0300 Subject: [PATCH 04/17] Start projects dynamically --- apps/expert/lib/expert/configuration.ex | 47 +++++++++---------------- apps/expert/lib/expert/state.ex | 34 ++++++++++++++---- apps/forge/lib/forge/project.ex | 33 +++++++++++++++++ 3 files changed, 77 insertions(+), 37 deletions(-) diff --git a/apps/expert/lib/expert/configuration.ex b/apps/expert/lib/expert/configuration.ex index 0dd67345..914bf657 100644 --- a/apps/expert/lib/expert/configuration.ex +++ b/apps/expert/lib/expert/configuration.ex @@ -30,11 +30,10 @@ 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 + def new(_root_uri, %Structures.ClientCapabilities{} = client_capabilities, client_name) do support = Support.new(client_capabilities) - projects = find_projects(root_uri) - %__MODULE__{support: support, projects: projects, client_name: client_name} + %__MODULE__{support: support, projects: [], client_name: client_name} |> tap(&set/1) end @@ -43,34 +42,6 @@ defmodule Expert.Configuration do struct!(__MODULE__, [support: Support.new()] ++ attrs) end - defp find_projects(root_uri) do - root_path = Forge.Document.Path.from_uri(root_uri) - root_mix_exs = Path.join(root_path, "mix.exs") - - projects = - if File.exists?(root_mix_exs) do - [Project.new(root_uri)] - else - find_multiroot_projects(root_path) - end - - if projects == [], do: [Project.new(root_uri)], else: projects - end - - defp find_multiroot_projects(root_path) do - mix_exs_blob = Path.join([root_path, "**", "mix.exs"]) - - for mix_exs_path <- Path.wildcard(mix_exs_blob), - "deps" not in Path.split(mix_exs_path) do - project_uri = - mix_exs_path - |> Path.dirname() - |> Forge.Document.Path.to_uri() - - Project.new(project_uri) - end - end - defp set(%__MODULE__{} = config) do :persistent_term.put(__MODULE__, config) end @@ -168,4 +139,18 @@ defmodule Expert.Configuration do defp maybe_add_watched_extensions(%__MODULE__{} = old_config, _) do {:ok, old_config} end + + @spec add_project(t, Project.t()) :: t + def add_project(%__MODULE__{} = config, %Project{} = project) do + if Enum.any?(config.projects, &(&1.root_uri == project.root_uri)) do + config + else + projects = [project | config.projects] + new_config = %__MODULE__{config | projects: projects} + + set(new_config) + + new_config + end + end end diff --git a/apps/expert/lib/expert/state.ex b/apps/expert/lib/expert/state.ex index a2c25dc0..2c49aba6 100644 --- a/apps/expert/lib/expert/state.ex +++ b/apps/expert/lib/expert/state.ex @@ -66,12 +66,6 @@ defmodule Expert.State do Transport.write(registrations()) - for project <- config.projects do - Logger.info("Starting project at uri #{project.root_uri}") - result = Expert.Project.Supervisor.start(project) - Logger.info("result: #{inspect(result)}") - end - {:ok, new_state} end @@ -79,6 +73,22 @@ defmodule Expert.State do {:error, :already_initialized} end + defp maybe_start_project(project, config) do + already_started? = + Enum.any?(config.projects, fn p -> + p.root_uri == project.root_uri + end) + + if already_started? do + :ok + else + Logger.info("Starting project at uri #{project.root_uri}") + result = Expert.Project.Supervisor.start(project) + Logger.info("result: #{inspect(result)}") + :ok + end + end + def in_flight?(%__MODULE__{} = state, request_id) do Map.has_key?(state.in_flight_requests, request_id) end @@ -180,6 +190,18 @@ defmodule Expert.State do language_id: language_id } = did_open.params.text_document + project = Project.find_project(uri) + config = state.configuration + + state = + if not is_nil(project) do + maybe_start_project(project, config) + config = Configuration.add_project(config, project) + %__MODULE__{state | configuration: config} + else + state + end + case Document.Store.open(uri, text, version, language_id) do :ok -> Logger.info("opened #{uri}") diff --git a/apps/forge/lib/forge/project.ex b/apps/forge/lib/forge/project.ex index cf978b82..844c01aa 100644 --- a/apps/forge/lib/forge/project.ex +++ b/apps/forge/lib/forge/project.ex @@ -341,4 +341,37 @@ defmodule Forge.Project do Forge.Path.parent_path?(document.path, root_path(project)) end) end + + def find_project(path) do + project_root = find_parent_root_dir(path) + + if is_nil(project_root) do + nil + else + new(project_root) + end + end + + defp find_parent_root_dir(path) do + path = Forge.Document.Path.from_uri(path) + path = path |> Path.expand() |> Path.dirname() + + segments = Path.split(path) + + traverse_path(segments) + end + + defp traverse_path([]), do: nil + + defp traverse_path(segments) do + path = Path.join(segments) + mix_exs_path = Path.join(path, "mix.exs") + + if File.exists?(mix_exs_path) do + Document.Path.to_uri(path) + else + {_, rest} = List.pop_at(segments, -1) + traverse_path(rest) + end + end end From c0c879add0d791be85bb9d5722c9ae0429403185 Mon Sep 17 00:00:00 2001 From: doorgan Date: Fri, 20 Jun 2025 22:57:08 -0300 Subject: [PATCH 05/17] Fix credo issue --- apps/expert/lib/expert/state.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/expert/lib/expert/state.ex b/apps/expert/lib/expert/state.ex index 2c49aba6..cddc87b7 100644 --- a/apps/expert/lib/expert/state.ex +++ b/apps/expert/lib/expert/state.ex @@ -194,12 +194,12 @@ defmodule Expert.State do config = state.configuration state = - if not is_nil(project) do + if is_nil(project) do + state + else maybe_start_project(project, config) config = Configuration.add_project(config, project) %__MODULE__{state | configuration: config} - else - state end case Document.Store.open(uri, text, version, language_id) do From cf379a5cda97789e6ded13489969bc3581745fda Mon Sep 17 00:00:00 2001 From: doorgan Date: Fri, 4 Jul 2025 23:14:42 -0300 Subject: [PATCH 06/17] chore: use lsp workspace folder functionality --- apps/expert/lib/expert.ex | 20 +++-- apps/expert/lib/expert/configuration.ex | 43 ++++++--- apps/expert/lib/expert/project/supervisor.ex | 10 --- .../provider/handlers/go_to_definition.ex | 2 +- apps/expert/lib/expert/state.ex | 87 +++++++++++++------ apps/forge/lib/forge/project.ex | 33 ------- 6 files changed, 102 insertions(+), 93 deletions(-) diff --git a/apps/expert/lib/expert.ex b/apps/expert/lib/expert.ex index ab033f4a..75473cf8 100644 --- a/apps/expert/lib/expert.ex +++ b/apps/expert/lib/expert.ex @@ -14,11 +14,11 @@ defmodule Expert do GenLSP.Notifications.TextDocumentDidChange, GenLSP.Notifications.WorkspaceDidChangeConfiguration, GenLSP.Notifications.WorkspaceDidChangeWatchedFiles, + GenLSP.Notifications.WorkspaceDidChangeWorkspaceFolders, GenLSP.Notifications.TextDocumentDidClose, GenLSP.Notifications.TextDocumentDidOpen, GenLSP.Notifications.TextDocumentDidSave, GenLSP.Notifications.Exit, - GenLSP.Notifications.Initialized, GenLSP.Requests.Shutdown ] @@ -47,13 +47,6 @@ defmodule Expert do case State.initialize(state, request) do {:ok, response, state} -> - # TODO: this should be gated behind the dynamic registration in the initialization params - registrations = registrations() - - if nil != GenLSP.request(lsp, registrations) do - Logger.error("Failed to register capability") - end - lsp = assign(lsp, state: state) {:ok, response} = Forge.Protocol.Convert.to_lsp(response) @@ -119,6 +112,17 @@ defmodule Expert do end end + def handle_notification(%GenLSP.Notifications.Initialized{}, lsp) do + Logger.info("Server initialized, registering capabilities") + registrations = registrations() + + if nil != GenLSP.request(lsp, registrations) do + Logger.error("Failed to register capability") + end + + {:noreply, lsp} + end + def handle_notification(%mod{} = notification, lsp) when mod in @server_specific_messages do with {:ok, notification} <- Convert.to_native(notification), {:ok, state} <- apply_to_state(assigns(lsp).state, notification) do diff --git a/apps/expert/lib/expert/configuration.ex b/apps/expert/lib/expert/configuration.ex index a9d3c971..a145377c 100644 --- a/apps/expert/lib/expert/configuration.ex +++ b/apps/expert/lib/expert/configuration.ex @@ -29,11 +29,16 @@ 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.WorkspaceFolder.t()], map(), String.t() | nil) :: t + def new(workspace_folders, %Structures.ClientCapabilities{} = client_capabilities, client_name) do support = Support.new(client_capabilities) - %__MODULE__{support: support, projects: [], client_name: client_name} + projects = + for %{uri: uri} <- workspace_folders do + Project.new(uri) + end + + %__MODULE__{support: support, projects: projects, client_name: client_name} |> tap(&set/1) end @@ -143,17 +148,29 @@ defmodule Expert.Configuration do {:ok, old_config} end - @spec add_project(t, Project.t()) :: t - def add_project(%__MODULE__{} = config, %Project{} = project) do - if Enum.any?(config.projects, &(&1.root_uri == project.root_uri)) do - config - else - projects = [project | config.projects] - new_config = %__MODULE__{config | projects: projects} + @spec add_projects(t, [Project.t()]) :: t + def add_projects(%__MODULE__{} = config, projects) do + new_config = + for project <- projects, reduce: config do + config -> + if Enum.any?(config.projects, &(&1.root_uri == project.root_uri)) do + config + else + projects = [project | config.projects] + %__MODULE__{config | projects: projects} + end + end + + set(new_config) - set(new_config) + new_config + end - new_config - end + @spec remove_projects(t, [String.t()]) :: t + def remove_projects(%__MODULE__{} = config, projects) do + new_projects = Enum.reject(config.projects, &(&1.root_uri in projects)) + new_config = %__MODULE__{config | projects: new_projects} + set(new_config) + new_config end end diff --git a/apps/expert/lib/expert/project/supervisor.ex b/apps/expert/lib/expert/project/supervisor.ex index d23d6d65..46e4a71a 100644 --- a/apps/expert/lib/expert/project/supervisor.ex +++ b/apps/expert/lib/expert/project/supervisor.ex @@ -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 diff --git a/apps/expert/lib/expert/provider/handlers/go_to_definition.ex b/apps/expert/lib/expert/provider/handlers/go_to_definition.ex index 7dca9617..86327d75 100644 --- a/apps/expert/lib/expert/provider/handlers/go_to_definition.ex +++ b/apps/expert/lib/expert/provider/handlers/go_to_definition.ex @@ -1,7 +1,7 @@ defmodule Expert.Provider.Handlers.GoToDefinition do alias Expert.Configuration - alias Forge.Project alias Expert.EngineApi + alias Forge.Project alias GenLSP.Requests alias GenLSP.Structures diff --git a/apps/expert/lib/expert/state.ex b/apps/expert/lib/expert/state.ex index d4dcd6cc..0b5d36d3 100644 --- a/apps/expert/lib/expert/state.ex +++ b/apps/expert/lib/expert/state.ex @@ -18,7 +18,8 @@ defmodule Expert.State do defstruct configuration: nil, initialized?: false, shutdown_received?: false, - in_flight_requests: %{} + in_flight_requests: %{}, + workspace_folders: [] @supported_code_actions [ Enumerations.CodeActionKind.quick_fix(), @@ -55,11 +56,15 @@ defmodule Expert.State do |> Forge.Workspace.new() |> Forge.Workspace.set_workspace() - config = Configuration.new(event.root_uri, event.capabilities, client_name) + config = Configuration.new(event.workspace_folders, event.capabilities, client_name) new_state = %__MODULE__{state | configuration: config, initialized?: true} response = initialize_result() + for project <- config.projects do + ensure_project_node_started(project) + end + {:ok, response, new_state} end @@ -100,6 +105,39 @@ defmodule Expert.State do {:ok, state} end + def apply(%__MODULE__{} = state, %Notifications.WorkspaceDidChangeWorkspaceFolders{ + params: + %Structures.DidChangeWorkspaceFoldersParams{ + event: %Structures.WorkspaceFoldersChangeEvent{added: added, removed: removed} + } = params + }) do + workspace_folders = [added | state.workspace_folders] -- removed + + removed_projects = + for %{uri: uri} <- removed do + project = Project.new(uri) + Logger.info("Stopping project at uri #{uri}") + Expert.Project.Supervisor.stop(project) + project + end + + added_projects = + for %{uri: uri} <- added do + project = Project.new(uri) + ensure_project_node_started(project) + project + end + + config = + state.configuration + |> Configuration.add_projects(added_projects) + |> Configuration.remove_projects(removed_projects) + + state = %__MODULE__{state | configuration: config, workspace_folders: workspace_folders} + + {:ok, state} + end + def apply(%__MODULE__{} = state, %GenLSP.Notifications.TextDocumentDidChange{params: params}) do uri = params.text_document.uri version = params.text_document.version @@ -136,18 +174,6 @@ defmodule Expert.State do language_id: language_id } = did_open.params.text_document - project = Project.find_project(uri) - config = state.configuration - - state = - if is_nil(project) do - state - else - maybe_start_project(project, config) - config = Configuration.add_project(config, project) - %__MODULE__{state | configuration: config} - end - case Document.Store.open(uri, text, version, language_id) do :ok -> Logger.info("################### opened #{uri}") @@ -216,19 +242,18 @@ defmodule Expert.State do {:ok, state} end - defp maybe_start_project(project, config) do - already_started? = - Enum.any?(config.projects, fn p -> - p.root_uri == project.root_uri - end) - - if already_started? do - :ok - else - Logger.info("Starting project at uri #{project.root_uri}") - result = Expert.Project.Supervisor.start(project) - Logger.info("result: #{inspect(result)}") - :ok + defp ensure_project_node_started(project) do + case Expert.Project.Supervisor.start(project) do + {:ok, _pid} -> + Logger.info("Project node started for #{project.name}") + + {:error, {reason, pid}} when reason in [:alread_started, :already_present] -> + {:ok, pid} + + {:error, reason} -> + Logger.error( + "Failed to start project node for #{project.name}: #{inspect(reason, pretty: true)}" + ) end end @@ -269,7 +294,13 @@ defmodule Expert.State do hover_provider: true, references_provider: true, text_document_sync: sync_options, - workspace_symbol_provider: true + workspace_symbol_provider: true, + workspace: %{ + workspace_folders: %Structures.WorkspaceFoldersServerCapabilities{ + supported: true, + change_notifications: true + } + } } %GenLSP.Structures.InitializeResult{ diff --git a/apps/forge/lib/forge/project.ex b/apps/forge/lib/forge/project.ex index e37a7e41..0a997e85 100644 --- a/apps/forge/lib/forge/project.ex +++ b/apps/forge/lib/forge/project.ex @@ -373,37 +373,4 @@ defmodule Forge.Project do Forge.Path.parent_path?(document.path, root_path(project)) end) end - - def find_project(path) do - project_root = find_parent_root_dir(path) - - if is_nil(project_root) do - nil - else - new(project_root) - end - end - - defp find_parent_root_dir(path) do - path = Forge.Document.Path.from_uri(path) - path = path |> Path.expand() |> Path.dirname() - - segments = Path.split(path) - - traverse_path(segments) - end - - defp traverse_path([]), do: nil - - defp traverse_path(segments) do - path = Path.join(segments) - mix_exs_path = Path.join(path, "mix.exs") - - if File.exists?(mix_exs_path) do - Document.Path.to_uri(path) - else - {_, rest} = List.pop_at(segments, -1) - traverse_path(rest) - end - end end From b4b54a89164f35bf37421bcdcd93404391880c13 Mon Sep 17 00:00:00 2001 From: doorgan Date: Sat, 5 Jul 2025 02:41:20 -0300 Subject: [PATCH 07/17] fix: support the case where the root folder contains subprojects --- apps/expert/lib/expert/active_projects.ex | 50 ++++++++++++++++ apps/expert/lib/expert/application.ex | 1 + apps/expert/lib/expert/configuration.ex | 42 ++----------- .../expert/provider/handlers/code_action.ex | 6 +- .../lib/expert/provider/handlers/code_lens.ex | 6 +- .../lib/expert/provider/handlers/commands.ex | 9 ++- .../expert/provider/handlers/completion.ex | 6 +- .../provider/handlers/document_symbols.ex | 6 +- .../provider/handlers/find_references.ex | 6 +- .../expert/provider/handlers/formatting.ex | 6 +- .../provider/handlers/go_to_definition.ex | 6 +- .../lib/expert/provider/handlers/hover.ex | 6 +- .../provider/handlers/workspace_symbol.ex | 7 ++- apps/expert/lib/expert/state.ex | 59 +++++++++++++------ .../provider/handlers/code_lens_test.exs | 4 +- .../handlers/find_references_test.exs | 4 +- .../handlers/go_to_definition_test.exs | 4 +- .../expert/provider/handlers/hover_test.exs | 5 +- apps/forge/lib/forge/project.ex | 49 +++++++++++++++ 19 files changed, 202 insertions(+), 80 deletions(-) create mode 100644 apps/expert/lib/expert/active_projects.ex diff --git a/apps/expert/lib/expert/active_projects.ex b/apps/expert/lib/expert/active_projects.ex new file mode 100644 index 00000000..affd41f8 --- /dev/null +++ b/apps/expert/lib/expert/active_projects.ex @@ -0,0 +1,50 @@ +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. + """ + + 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]) + {: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 +end diff --git a/apps/expert/lib/expert/application.ex b/apps/expert/lib/expert/application.ex index de275596..a8e3dcaa 100644 --- a/apps/expert/lib/expert/application.ex +++ b/apps/expert/lib/expert/application.ex @@ -18,6 +18,7 @@ defmodule Expert.Application do {GenLSP.Assigns, [name: Expert.Assigns]}, {Task.Supervisor, name: :expert_task_queue}, {GenLSP.Buffer, name: Expert.Buffer}, + {Expert.ActiveProjects, []}, {Expert, buffer: Expert.Buffer, task_supervisor: :expert_task_queue, diff --git a/apps/expert/lib/expert/configuration.ex b/apps/expert/lib/expert/configuration.ex index a145377c..c9d7d62d 100644 --- a/apps/expert/lib/expert/configuration.ex +++ b/apps/expert/lib/expert/configuration.ex @@ -5,20 +5,17 @@ defmodule Expert.Configuration do alias Expert.Configuration.Support alias Expert.Dialyzer - alias Forge.Project alias Forge.Protocol.Id alias GenLSP.Notifications.WorkspaceDidChangeConfiguration alias GenLSP.Requests alias GenLSP.Structures - defstruct projects: [], - support: nil, + defstruct support: nil, client_name: nil, additional_watched_extensions: nil, dialyzer_enabled?: false @type t :: %__MODULE__{ - projects: [Project.t()], support: support | nil, client_name: String.t() | nil, additional_watched_extensions: [String.t()] | nil, @@ -29,16 +26,11 @@ defmodule Expert.Configuration do @dialyzer {:nowarn_function, set_dialyzer_enabled: 2} - @spec new([Structures.WorkspaceFolder.t()], map(), String.t() | nil) :: t - def new(workspace_folders, %Structures.ClientCapabilities{} = client_capabilities, client_name) do + @spec new(map(), String.t() | nil) :: t + def new(%Structures.ClientCapabilities{} = client_capabilities, client_name) do support = Support.new(client_capabilities) - projects = - for %{uri: uri} <- workspace_folders do - Project.new(uri) - end - - %__MODULE__{support: support, projects: projects, client_name: client_name} + %__MODULE__{support: support, client_name: client_name} |> tap(&set/1) end @@ -147,30 +139,4 @@ defmodule Expert.Configuration do defp maybe_add_watched_extensions(%__MODULE__{} = old_config, _) do {:ok, old_config} end - - @spec add_projects(t, [Project.t()]) :: t - def add_projects(%__MODULE__{} = config, projects) do - new_config = - for project <- projects, reduce: config do - config -> - if Enum.any?(config.projects, &(&1.root_uri == project.root_uri)) do - config - else - projects = [project | config.projects] - %__MODULE__{config | projects: projects} - end - end - - set(new_config) - - new_config - end - - @spec remove_projects(t, [String.t()]) :: t - def remove_projects(%__MODULE__{} = config, projects) do - new_projects = Enum.reject(config.projects, &(&1.root_uri in projects)) - new_config = %__MODULE__{config | projects: new_projects} - set(new_config) - new_config - end end diff --git a/apps/expert/lib/expert/provider/handlers/code_action.ex b/apps/expert/lib/expert/provider/handlers/code_action.ex index ed302d01..1110cd4d 100644 --- a/apps/expert/lib/expert/provider/handlers/code_action.ex +++ b/apps/expert/lib/expert/provider/handlers/code_action.ex @@ -1,4 +1,5 @@ defmodule Expert.Provider.Handlers.CodeAction do + alias Expert.ActiveProjects alias Expert.Configuration alias Expert.EngineApi alias Forge.CodeAction @@ -8,10 +9,11 @@ defmodule Expert.Provider.Handlers.CodeAction do def handle( %Requests.TextDocumentCodeAction{params: %Structures.CodeActionParams{} = params}, - %Configuration{} = config + %Configuration{} ) do document = Forge.Document.Container.context_document(params, nil) - project = Project.project_for_document(config.projects, document) + projects = ActiveProjects.projects() + project = Project.project_for_document(projects, document) diagnostics = Enum.map(params.context.diagnostics, &to_code_action_diagnostic/1) code_actions = diff --git a/apps/expert/lib/expert/provider/handlers/code_lens.ex b/apps/expert/lib/expert/provider/handlers/code_lens.ex index 3fefcf21..2812a159 100644 --- a/apps/expert/lib/expert/provider/handlers/code_lens.ex +++ b/apps/expert/lib/expert/provider/handlers/code_lens.ex @@ -1,4 +1,5 @@ defmodule Expert.Provider.Handlers.CodeLens do + alias Expert.ActiveProjects alias Expert.Configuration alias Expert.EngineApi alias Expert.Provider.Handlers @@ -14,10 +15,11 @@ defmodule Expert.Provider.Handlers.CodeLens do def handle( %Requests.TextDocumentCodeLens{params: %Structures.CodeLensParams{} = params}, - %Configuration{} = config + %Configuration{} ) do document = Document.Container.context_document(params, nil) - project = Project.project_for_document(config.projects, document) + projects = ActiveProjects.projects() + project = Project.project_for_document(projects, document) document = Document.Container.context_document(params, nil) diff --git a/apps/expert/lib/expert/provider/handlers/commands.ex b/apps/expert/lib/expert/provider/handlers/commands.ex index c8a6b7bf..156ea128 100644 --- a/apps/expert/lib/expert/provider/handlers/commands.ex +++ b/apps/expert/lib/expert/provider/handlers/commands.ex @@ -1,4 +1,5 @@ defmodule Expert.Provider.Handlers.Commands do + alias Expert.ActiveProjects alias Expert.Configuration alias Expert.EngineApi alias Forge.Project @@ -25,14 +26,16 @@ defmodule Expert.Provider.Handlers.Commands do def handle( %Requests.WorkspaceExecuteCommand{params: %Structures.ExecuteCommandParams{} = params}, - %Configuration{} = config + %Configuration{} ) do + projects = ActiveProjects.projects() + response = case params.command do @reindex_name -> - project_names = Enum.map_join(config.projects, ", ", &Project.name/1) + project_names = Enum.map_join(projects, ", ", &Project.name/1) Logger.info("Reindex #{project_names}") - reindex_all(config.projects) + reindex_all(projects) invalid -> message = "#{invalid} is not a valid command" diff --git a/apps/expert/lib/expert/provider/handlers/completion.ex b/apps/expert/lib/expert/provider/handlers/completion.ex index 293fc6a8..32bea113 100644 --- a/apps/expert/lib/expert/provider/handlers/completion.ex +++ b/apps/expert/lib/expert/provider/handlers/completion.ex @@ -1,4 +1,5 @@ defmodule Expert.Provider.Handlers.Completion do + alias Expert.ActiveProjects alias Expert.CodeIntelligence alias Expert.Configuration alias Forge.Ast @@ -14,10 +15,11 @@ defmodule Expert.Provider.Handlers.Completion do %Requests.TextDocumentCompletion{ params: %Structures.CompletionParams{} = params }, - %Configuration{} = config + %Configuration{} ) do document = Document.Container.context_document(params, nil) - project = Project.project_for_document(config.projects, document) + projects = ActiveProjects.projects() + project = Project.project_for_document(projects, document) completions = CodeIntelligence.Completion.complete( diff --git a/apps/expert/lib/expert/provider/handlers/document_symbols.ex b/apps/expert/lib/expert/provider/handlers/document_symbols.ex index 6fc11838..37945cac 100644 --- a/apps/expert/lib/expert/provider/handlers/document_symbols.ex +++ b/apps/expert/lib/expert/provider/handlers/document_symbols.ex @@ -1,4 +1,5 @@ defmodule Expert.Provider.Handlers.DocumentSymbols do + alias Expert.ActiveProjects alias Expert.Configuration alias Expert.EngineApi alias Forge.CodeIntelligence.Symbols @@ -8,9 +9,10 @@ defmodule Expert.Provider.Handlers.DocumentSymbols do alias GenLSP.Requests alias GenLSP.Structures - def handle(%Requests.TextDocumentDocumentSymbol{} = request, %Configuration{} = config) do + def handle(%Requests.TextDocumentDocumentSymbol{} = request, %Configuration{}) do document = Document.Container.context_document(request.params, nil) - project = Project.project_for_document(config.projects, document) + projects = ActiveProjects.projects() + project = Project.project_for_document(projects, document) symbols = project diff --git a/apps/expert/lib/expert/provider/handlers/find_references.ex b/apps/expert/lib/expert/provider/handlers/find_references.ex index 847c85d6..bd3654ef 100644 --- a/apps/expert/lib/expert/provider/handlers/find_references.ex +++ b/apps/expert/lib/expert/provider/handlers/find_references.ex @@ -1,4 +1,5 @@ defmodule Expert.Provider.Handlers.FindReferences do + alias Expert.ActiveProjects alias Expert.Configuration alias Expert.EngineApi alias Forge.Ast @@ -11,10 +12,11 @@ defmodule Expert.Provider.Handlers.FindReferences do def handle( %TextDocumentReferences{params: %Structures.ReferenceParams{} = params}, - %Configuration{} = config + %Configuration{} ) do document = Forge.Document.Container.context_document(params, nil) - project = Project.project_for_document(config.projects, document) + projects = ActiveProjects.projects() + project = Project.project_for_document(projects, document) include_declaration? = !!params.context.include_declaration locations = diff --git a/apps/expert/lib/expert/provider/handlers/formatting.ex b/apps/expert/lib/expert/provider/handlers/formatting.ex index a90bd2d2..3b853a86 100644 --- a/apps/expert/lib/expert/provider/handlers/formatting.ex +++ b/apps/expert/lib/expert/provider/handlers/formatting.ex @@ -1,4 +1,5 @@ defmodule Expert.Provider.Handlers.Formatting do + alias Expert.ActiveProjects alias Expert.Configuration alias Expert.EngineApi alias Forge.Document.Changes @@ -10,10 +11,11 @@ defmodule Expert.Provider.Handlers.Formatting do def handle( %Requests.TextDocumentFormatting{params: %Structures.DocumentFormattingParams{} = params}, - %Configuration{} = config + %Configuration{} ) do document = Forge.Document.Container.context_document(params, nil) - project = Project.project_for_document(config.projects, document) + projects = ActiveProjects.projects() + project = Project.project_for_document(projects, document) case EngineApi.format(project, document) do {:ok, %Changes{} = document_edits} -> diff --git a/apps/expert/lib/expert/provider/handlers/go_to_definition.ex b/apps/expert/lib/expert/provider/handlers/go_to_definition.ex index 86327d75..bd641b00 100644 --- a/apps/expert/lib/expert/provider/handlers/go_to_definition.ex +++ b/apps/expert/lib/expert/provider/handlers/go_to_definition.ex @@ -1,4 +1,5 @@ defmodule Expert.Provider.Handlers.GoToDefinition do + alias Expert.ActiveProjects alias Expert.Configuration alias Expert.EngineApi alias Forge.Project @@ -11,10 +12,11 @@ defmodule Expert.Provider.Handlers.GoToDefinition do %Requests.TextDocumentDefinition{ params: %Structures.DefinitionParams{} = params }, - %Configuration{} = config + %Configuration{} ) do document = Forge.Document.Container.context_document(params, nil) - project = Project.project_for_document(config.projects, document) + projects = ActiveProjects.projects() + project = Project.project_for_document(projects, document) case EngineApi.definition(project, document, params.position) do {:ok, native_location} -> diff --git a/apps/expert/lib/expert/provider/handlers/hover.ex b/apps/expert/lib/expert/provider/handlers/hover.ex index 90b55269..09b7bcb2 100644 --- a/apps/expert/lib/expert/provider/handlers/hover.ex +++ b/apps/expert/lib/expert/provider/handlers/hover.ex @@ -1,4 +1,5 @@ defmodule Expert.Provider.Handlers.Hover do + alias Expert.ActiveProjects alias Expert.Configuration alias Expert.EngineApi alias Expert.Provider.Markdown @@ -17,10 +18,11 @@ defmodule Expert.Provider.Handlers.Hover do %Requests.TextDocumentHover{ params: %Structures.HoverParams{} = params }, - %Configuration{} = config + %Configuration{} ) do document = Document.Container.context_document(params, nil) - project = Project.project_for_document(config.projects, document) + projects = ActiveProjects.projects() + project = Project.project_for_document(projects, document) maybe_hover = with {:ok, _document, %Ast.Analysis{} = analysis} <- diff --git a/apps/expert/lib/expert/provider/handlers/workspace_symbol.ex b/apps/expert/lib/expert/provider/handlers/workspace_symbol.ex index bd171773..fbf24765 100644 --- a/apps/expert/lib/expert/provider/handlers/workspace_symbol.ex +++ b/apps/expert/lib/expert/provider/handlers/workspace_symbol.ex @@ -1,4 +1,5 @@ defmodule Expert.Provider.Handlers.WorkspaceSymbol do + alias Expert.ActiveProjects alias Expert.Configuration alias Expert.EngineApi alias Forge.CodeIntelligence.Symbols @@ -11,11 +12,13 @@ defmodule Expert.Provider.Handlers.WorkspaceSymbol do def handle( %Requests.WorkspaceSymbol{params: %Structures.WorkspaceSymbolParams{} = params} = request, - %Configuration{} = config + %Configuration{} ) do + projects = ActiveProjects.projects() + symbols = if String.length(params.query) > 1 do - Enum.flat_map(config.projects, &gather_symbols(&1, request)) + Enum.flat_map(projects, &gather_symbols(&1, request)) else [] end diff --git a/apps/expert/lib/expert/state.ex b/apps/expert/lib/expert/state.ex index 0b5d36d3..b40fdd1b 100644 --- a/apps/expert/lib/expert/state.ex +++ b/apps/expert/lib/expert/state.ex @@ -1,4 +1,5 @@ defmodule Expert.State do + alias Expert.ActiveProjects alias Expert.CodeIntelligence alias Expert.Configuration alias Expert.EngineApi @@ -56,12 +57,20 @@ defmodule Expert.State do |> Forge.Workspace.new() |> Forge.Workspace.set_workspace() - config = Configuration.new(event.workspace_folders, event.capabilities, client_name) + config = Configuration.new(event.capabilities, client_name) new_state = %__MODULE__{state | configuration: config, initialized?: true} response = initialize_result() - for project <- config.projects do + projects = + for %{uri: uri} <- event.workspace_folders do + Project.new(uri) + end + |> Enum.filter(& &1.mix_project?) + + ActiveProjects.set_projects(projects) + + for project <- projects do ensure_project_node_started(project) end @@ -106,12 +115,11 @@ defmodule Expert.State do end def apply(%__MODULE__{} = state, %Notifications.WorkspaceDidChangeWorkspaceFolders{ - params: - %Structures.DidChangeWorkspaceFoldersParams{ - event: %Structures.WorkspaceFoldersChangeEvent{added: added, removed: removed} - } = params + params: %Structures.DidChangeWorkspaceFoldersParams{ + event: %Structures.WorkspaceFoldersChangeEvent{added: added, removed: removed} + } }) do - workspace_folders = [added | state.workspace_folders] -- removed + workspace_folders = Enum.uniq((added ++ state.workspace_folders) -- removed) removed_projects = for %{uri: uri} <- removed do @@ -128,12 +136,12 @@ defmodule Expert.State do project end - config = - state.configuration - |> Configuration.add_projects(added_projects) - |> Configuration.remove_projects(removed_projects) + projects = + Enum.uniq((added_projects ++ ActiveProjects.projects()) -- removed_projects) - state = %__MODULE__{state | configuration: config, workspace_folders: workspace_folders} + ActiveProjects.set_projects(projects) + + state = %__MODULE__{state | workspace_folders: workspace_folders} {:ok, state} end @@ -141,7 +149,8 @@ defmodule Expert.State do def apply(%__MODULE__{} = state, %GenLSP.Notifications.TextDocumentDidChange{params: params}) do uri = params.text_document.uri version = params.text_document.version - project = Project.project_for_uri(state.configuration.projects, uri) + projects = ActiveProjects.projects() + project = Project.project_for_uri(projects, uri) case Document.Store.get_and_update( uri, @@ -174,10 +183,26 @@ defmodule Expert.State do language_id: language_id } = did_open.params.text_document + config = state.configuration + + project = + case Enum.find(ActiveProjects.projects(), &Project.within_project?(&1, uri)) do + nil -> + Project.find_project(uri) + + project -> + project + end + + if project do + ensure_project_node_started(project) + ActiveProjects.add_projects([project]) + end + case Document.Store.open(uri, text, version, language_id) do :ok -> Logger.info("################### opened #{uri}") - {:ok, state} + {:ok, %{state | configuration: config}} error -> Logger.error("################## Could not open #{uri} #{inspect(error)}") @@ -245,14 +270,14 @@ defmodule Expert.State do defp ensure_project_node_started(project) do case Expert.Project.Supervisor.start(project) do {:ok, _pid} -> - Logger.info("Project node started for #{project.name}") + Logger.info("Project node started for #{Project.name(project)}") - {:error, {reason, pid}} when reason in [:alread_started, :already_present] -> + {:error, {reason, pid}} when reason in [:already_started, :already_present] -> {:ok, pid} {:error, reason} -> Logger.error( - "Failed to start project node for #{project.name}: #{inspect(reason, pretty: true)}" + "Failed to start project node for #{Project.name(project)}: #{inspect(reason, pretty: true)}" ) end end diff --git a/apps/expert/test/expert/provider/handlers/code_lens_test.exs b/apps/expert/test/expert/provider/handlers/code_lens_test.exs index 468d9b8b..8d72d3d4 100644 --- a/apps/expert/test/expert/provider/handlers/code_lens_test.exs +++ b/apps/expert/test/expert/provider/handlers/code_lens_test.exs @@ -21,6 +21,7 @@ defmodule Expert.Provider.Handlers.CodeLensTest do start_supervised!({DynamicSupervisor, Expert.Project.DynamicSupervisor.options()}) start_supervised!({Expert.Project.Supervisor, project}) + start_supervised!({Expert.ActiveProjects, []}) EngineApi.register_listener(project, self(), [project_compiled()]) EngineApi.schedule_compile(project, true) @@ -57,7 +58,8 @@ defmodule Expert.Provider.Handlers.CodeLensTest do end def handle(request, project) do - config = Expert.Configuration.new(projects: [project]) + Expert.ActiveProjects.add_projects([project]) + config = Expert.Configuration.new() Handlers.CodeLens.handle(request, config) end diff --git a/apps/expert/test/expert/provider/handlers/find_references_test.exs b/apps/expert/test/expert/provider/handlers/find_references_test.exs index 02eb34a1..6539c96c 100644 --- a/apps/expert/test/expert/provider/handlers/find_references_test.exs +++ b/apps/expert/test/expert/provider/handlers/find_references_test.exs @@ -15,6 +15,7 @@ defmodule Expert.Provider.Handlers.FindReferencesTest do setup_all do start_supervised(Expert.Application.document_store_child_spec()) + start_supervised!({Expert.ActiveProjects, []}) :ok end @@ -45,7 +46,8 @@ defmodule Expert.Provider.Handlers.FindReferencesTest do end def handle(request, project) do - config = Expert.Configuration.new(projects: [project]) + Expert.ActiveProjects.add_projects([project]) + config = Expert.Configuration.new() Handlers.FindReferences.handle(request, config) end diff --git a/apps/expert/test/expert/provider/handlers/go_to_definition_test.exs b/apps/expert/test/expert/provider/handlers/go_to_definition_test.exs index 8772934e..dcacd6a0 100644 --- a/apps/expert/test/expert/provider/handlers/go_to_definition_test.exs +++ b/apps/expert/test/expert/provider/handlers/go_to_definition_test.exs @@ -18,6 +18,7 @@ defmodule Expert.Provider.Handlers.GoToDefinitionTest do start_supervised!(Expert.Application.document_store_child_spec()) start_supervised!({DynamicSupervisor, Expert.Project.DynamicSupervisor.options()}) start_supervised!({Expert.Project.Supervisor, project}) + start_supervised!({Expert.ActiveProjects, []}) EngineApi.register_listener(project, self(), [ project_compiled(), @@ -53,7 +54,8 @@ defmodule Expert.Provider.Handlers.GoToDefinitionTest do end def handle(request, project) do - config = Expert.Configuration.new(projects: [project]) + Expert.ActiveProjects.add_projects([project]) + config = Expert.Configuration.new() Handlers.GoToDefinition.handle(request, config) end diff --git a/apps/expert/test/expert/provider/handlers/hover_test.exs b/apps/expert/test/expert/provider/handlers/hover_test.exs index ab5916f7..d28a1f2d 100644 --- a/apps/expert/test/expert/provider/handlers/hover_test.exs +++ b/apps/expert/test/expert/provider/handlers/hover_test.exs @@ -23,6 +23,7 @@ defmodule Expert.Provider.Handlers.HoverTest do start_supervised!(Expert.Application.document_store_child_spec()) start_supervised!({DynamicSupervisor, Expert.Project.DynamicSupervisor.options()}) start_supervised!({Expert.Project.Supervisor, project}) + start_supervised!({Expert.ActiveProjects, []}) :ok = EngineApi.register_listener(project, self(), [Messages.project_compiled()]) assert_receive Messages.project_compiled(), 5000 @@ -719,10 +720,12 @@ defmodule Expert.Provider.Handlers.HoverTest do end defp hover(project, hovered) do + Expert.ActiveProjects.add_projects([project]) + with {position, hovered} <- pop_cursor(hovered), {:ok, document} <- document_with_content(project, hovered), {:ok, request} <- hover_request(document.uri, position) do - config = Expert.Configuration.new(projects: [project]) + config = Expert.Configuration.new() Handlers.Hover.handle(request, config) end end diff --git a/apps/forge/lib/forge/project.ex b/apps/forge/lib/forge/project.ex index 0a997e85..16086c87 100644 --- a/apps/forge/lib/forge/project.ex +++ b/apps/forge/lib/forge/project.ex @@ -373,4 +373,53 @@ defmodule Forge.Project do Forge.Path.parent_path?(document.path, root_path(project)) end) end + + @doc """ + Checks if the given path is within the project directory. + + If the path is within a subdirectory of the project and a + mix file exists, it returns false. + """ + def within_project?(%__MODULE__{} = project, path) do + root_path = find_parent_root_dir(path) + project_path = root_path(project) + + Forge.Path.parent_path?(root_path, project_path) + end + + @doc """ + Finds or creates the project for the given path. + """ + def find_project(path) do + project_root = find_parent_root_dir(path) + + if is_nil(project_root) do + nil + else + new(project_root) + end + end + + def find_parent_root_dir(path) do + path = Forge.Document.Path.from_uri(path) + path = path |> Path.expand() |> Path.dirname() + + segments = Path.split(path) + + traverse_path(segments) + end + + defp traverse_path([]), do: nil + + defp traverse_path(segments) do + path = Path.join(segments) + mix_exs_path = Path.join(path, "mix.exs") + + if File.exists?(mix_exs_path) do + Document.Path.to_uri(path) + else + {_, rest} = List.pop_at(segments, -1) + traverse_path(rest) + end + end end From 90ab70a573f60b062af24c7bc3b79ec39e55391d Mon Sep 17 00:00:00 2001 From: doorgan Date: Wed, 9 Jul 2025 17:35:46 -0300 Subject: [PATCH 08/17] chore: add tests --- apps/expert/lib/expert/configuration.ex | 2 +- apps/expert/lib/expert/project/supervisor.ex | 2 +- apps/expert/lib/expert/state.ex | 54 +++- apps/expert/test/expert/expert_test.exs | 288 ++++++++++++++++++ .../workspace_folders/main/.formatter.exs | 4 + .../workspace_folders/main/.gitignore | 23 ++ .../fixtures/workspace_folders/main/README.md | 21 ++ .../workspace_folders/main/lib/main.ex | 18 ++ .../fixtures/workspace_folders/main/mix.exs | 30 ++ .../workspace_folders/main/test/main_test.exs | 8 + .../main/test/test_helper.exs | 1 + .../secondary/.formatter.exs | 4 + .../workspace_folders/secondary/.gitignore | 23 ++ .../workspace_folders/secondary/README.md | 21 ++ .../secondary/lib/secondary.ex | 18 ++ .../workspace_folders/secondary/mix.exs | 30 ++ .../secondary/test/secondary_test.exs | 8 + .../secondary/test/test_helper.exs | 1 + 18 files changed, 540 insertions(+), 16 deletions(-) create mode 100644 apps/expert/test/expert/expert_test.exs create mode 100644 apps/forge/test/fixtures/workspace_folders/main/.formatter.exs create mode 100644 apps/forge/test/fixtures/workspace_folders/main/.gitignore create mode 100644 apps/forge/test/fixtures/workspace_folders/main/README.md create mode 100644 apps/forge/test/fixtures/workspace_folders/main/lib/main.ex create mode 100644 apps/forge/test/fixtures/workspace_folders/main/mix.exs create mode 100644 apps/forge/test/fixtures/workspace_folders/main/test/main_test.exs create mode 100644 apps/forge/test/fixtures/workspace_folders/main/test/test_helper.exs create mode 100644 apps/forge/test/fixtures/workspace_folders/secondary/.formatter.exs create mode 100644 apps/forge/test/fixtures/workspace_folders/secondary/.gitignore create mode 100644 apps/forge/test/fixtures/workspace_folders/secondary/README.md create mode 100644 apps/forge/test/fixtures/workspace_folders/secondary/lib/secondary.ex create mode 100644 apps/forge/test/fixtures/workspace_folders/secondary/mix.exs create mode 100644 apps/forge/test/fixtures/workspace_folders/secondary/test/secondary_test.exs create mode 100644 apps/forge/test/fixtures/workspace_folders/secondary/test/test_helper.exs diff --git a/apps/expert/lib/expert/configuration.ex b/apps/expert/lib/expert/configuration.ex index a4111f30..7ee3567e 100644 --- a/apps/expert/lib/expert/configuration.ex +++ b/apps/expert/lib/expert/configuration.ex @@ -26,7 +26,7 @@ defmodule Expert.Configuration do @dialyzer {:nowarn_function, set_dialyzer_enabled: 2} - @spec new(map(), String.t() | nil) :: t + @spec new(Structures.ClientCapabilities.t(), String.t() | nil) :: t def new(%Structures.ClientCapabilities{} = client_capabilities, client_name) do support = Support.new(client_capabilities) diff --git a/apps/expert/lib/expert/project/supervisor.ex b/apps/expert/lib/expert/project/supervisor.ex index 46e4a71a..009bcc8b 100644 --- a/apps/expert/lib/expert/project/supervisor.ex +++ b/apps/expert/lib/expert/project/supervisor.ex @@ -39,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 diff --git a/apps/expert/lib/expert/state.ex b/apps/expert/lib/expert/state.ex index 45b4829b..11850269 100644 --- a/apps/expert/lib/expert/state.ex +++ b/apps/expert/lib/expert/state.ex @@ -51,7 +51,7 @@ defmodule Expert.State do _ -> nil end - root_path = Forge.Document.Path.from_uri(event.root_uri) + root_path = Document.Path.from_uri(event.root_uri) root_path |> Forge.Workspace.new() @@ -126,8 +126,19 @@ defmodule Expert.State do removed_projects = for %{uri: uri} <- removed do project = Project.new(uri) - Logger.info("Stopping project at uri #{uri}") + Expert.Project.Supervisor.stop(project) + + GenLSP.notify( + Expert.get_lsp(), + %GenLSP.Notifications.WindowLogMessage{ + params: %GenLSP.Structures.LogMessageParams{ + type: GenLSP.Enumerations.MessageType.info(), + message: "Stopping project node for #{Project.name(project)}" + } + } + ) + project end @@ -138,10 +149,8 @@ defmodule Expert.State do project end - projects = - Enum.uniq((added_projects ++ ActiveProjects.projects()) -- removed_projects) - - ActiveProjects.set_projects(projects) + ActiveProjects.add_projects(added_projects) + ActiveProjects.remove_projects(removed_projects) state = %__MODULE__{state | workspace_folders: workspace_folders} @@ -188,12 +197,8 @@ defmodule Expert.State do config = state.configuration project = - case Enum.find(ActiveProjects.projects(), &Project.within_project?(&1, uri)) do - nil -> - Project.find_project(uri) - - project -> - project + with nil <- Enum.find(ActiveProjects.projects(), &Project.within_project?(&1, uri)) do + Project.find_project(uri) end if project do @@ -203,11 +208,10 @@ defmodule Expert.State do case Document.Store.open(uri, text, version, language_id) do :ok -> - Logger.info("################### opened #{uri}") {:ok, %{state | configuration: config}} error -> - Logger.error("################## Could not open #{uri} #{inspect(error)}") + Logger.error("Could not open #{uri} #{inspect(error)}") error end end @@ -274,6 +278,16 @@ defmodule Expert.State do {:ok, _pid} -> Logger.info("Project node started for #{Project.name(project)}") + GenLSP.notify( + Expert.get_lsp(), + %GenLSP.Notifications.WindowLogMessage{ + params: %GenLSP.Structures.LogMessageParams{ + type: GenLSP.Enumerations.MessageType.info(), + message: "Started project node for #{Project.name(project)}" + } + } + ) + {:error, {reason, pid}} when reason in [:already_started, :already_present] -> {:ok, pid} @@ -281,6 +295,18 @@ defmodule Expert.State do Logger.error( "Failed to start project node for #{Project.name(project)}: #{inspect(reason, pretty: true)}" ) + + GenLSP.notify( + Expert.get_lsp(), + %GenLSP.Notifications.WindowLogMessage{ + params: %GenLSP.Structures.LogMessageParams{ + type: GenLSP.Enumerations.MessageType.error(), + message: "Failed to start project node for #{Project.name(project)}" + } + } + ) + + {:error, reason} end end diff --git a/apps/expert/test/expert/expert_test.exs b/apps/expert/test/expert/expert_test.exs new file mode 100644 index 00000000..d8a1843f --- /dev/null +++ b/apps/expert/test/expert/expert_test.exs @@ -0,0 +1,288 @@ +defmodule ExpertTest do + alias Forge.Document + alias Forge.Project + + import GenLSP.Test + import Forge.Test.Fixtures + + use ExUnit.Case, async: false + + setup_all do + start_supervised!({Document.Store, derive: [analysis: &Forge.Ast.analyze/1]}) + + project_root = fixtures_path() |> Path.join("workspace_folders") + + main_project = + project_root + |> Path.join("main") + |> Document.Path.to_uri() + |> Project.new() + + secondary_project = + project_root + |> Path.join("secondary") + |> Document.Path.to_uri() + |> Project.new() + + [project_root: project_root, main_project: main_project, secondary_project: secondary_project] + end + + setup do + start_supervised!({Task.Supervisor, name: :expert_task_queue}) + start_supervised!({DynamicSupervisor, name: Expert.DynamicSupervisor}) + start_supervised!({DynamicSupervisor, Expert.Project.DynamicSupervisor.options()}) + start_supervised!({Expert.ActiveProjects, []}) + + server = + server(Expert, + task_supervisor: :expert_task_queue, + dynamic_supervisor: Expert.DynamicSupervisor + ) + + Process.link(server.lsp) + + client = client(server) + + [server: server, client: client] + end + + def initialize_request(root_path, opts \\ []) do + id = opts[:id] || 1 + projects = opts[:projects] || [] + + %{ + method: "initialize", + id: id, + jsonrpc: "2.0", + params: %{ + rootUri: Document.Path.to_uri(root_path), + initializationOptions: %{}, + capabilities: %{ + workspace: %{ + workspaceFolders: true + } + }, + workspaceFolders: + Enum.map(projects, fn project -> + %{uri: project.root_uri, name: Project.name(project)} + end) + } + } + end + + def project_alive?(project) do + project |> Expert.Project.Supervisor.name() |> Process.whereis() |> is_pid() + end + + describe "initialize request" do + test "starts a project at the initial workspace folders", %{ + client: client, + project_root: project_root, + main_project: main_project + } do + assert :ok = + request( + client, + initialize_request(project_root, id: 1, projects: [main_project]) + ) + + assert_result(1, %{ + "capabilities" => %{"workspace" => %{"workspaceFolders" => %{"supported" => true}}} + }) + + expected_message = "Started project node for #{Project.name(main_project)}" + + assert_notification( + "window/logMessage", + %{ + "type" => 3, + "message" => ^expected_message + } + ) + + assert [project] = Expert.ActiveProjects.projects() + assert project.root_uri == main_project.root_uri + + assert project_alive?(main_project) + end + end + + describe "workspace folders" do + test "starts project nodes when adding workspace folders", %{ + client: client, + project_root: project_root, + main_project: main_project, + secondary_project: secondary_project + } do + assert :ok = + request( + client, + initialize_request(project_root, id: 1, projects: [main_project]) + ) + + assert_result(1, _) + + expected_message = "Started project node for #{Project.name(main_project)}" + + assert_notification( + "window/logMessage", + %{ + "type" => 3, + "message" => ^expected_message + } + ) + + assert [_project_1] = Expert.ActiveProjects.projects() + + assert :ok = + notify( + client, + %{ + method: "workspace/didChangeWorkspaceFolders", + jsonrpc: "2.0", + params: %{ + event: %{ + added: [ + %{uri: secondary_project.root_uri, name: secondary_project.root_uri} + ], + removed: [] + } + } + } + ) + + expected_message = "Started project node for #{Project.name(secondary_project)}" + + assert_notification( + "window/logMessage", + %{ + "type" => 3, + "message" => ^expected_message + } + ) + + assert [project_1, project_2] = Expert.ActiveProjects.projects() + assert project_1.root_uri == main_project.root_uri + assert project_alive?(main_project) + + assert project_2.root_uri == secondary_project.root_uri + assert project_alive?(secondary_project) + end + + test "can remove workspace folders", %{ + client: client, + project_root: project_root, + main_project: main_project + } do + assert :ok = + request( + client, + initialize_request(project_root, id: 1, projects: [main_project]) + ) + + assert_result(1, _) + expected_message = "Started project node for #{Project.name(main_project)}" + + assert_notification( + "window/logMessage", + %{ + "type" => 3, + "message" => ^expected_message + } + ) + + assert [project] = Expert.ActiveProjects.projects() + assert project.root_uri == main_project.root_uri + assert project_alive?(main_project) + + assert :ok = + notify( + client, + %{ + method: "workspace/didChangeWorkspaceFolders", + jsonrpc: "2.0", + params: %{ + event: %{ + added: [], + removed: [ + %{uri: main_project.root_uri, name: main_project.root_uri} + ] + } + } + } + ) + + expected_message = "Stopping project node for #{Project.name(main_project)}" + + assert_notification( + "window/logMessage", + %{ + "type" => 3, + "message" => ^expected_message + } + ) + + assert [] = Expert.ActiveProjects.projects() + refute project_alive?(main_project) + end + end + + describe "opening files" do + test "starts a project node when opening a file in a folder not specified as workspace folder", + %{ + client: client, + project_root: project_root, + main_project: main_project, + secondary_project: secondary_project + } do + assert :ok = + request( + client, + initialize_request(project_root, id: 1, projects: [main_project]) + ) + + expected_message = "Started project node for #{Project.name(main_project)}" + + assert_notification( + "window/logMessage", + %{ + "type" => 3, + "message" => ^expected_message + } + ) + + file_uri = Path.join([secondary_project.root_uri, "lib", "secondary.ex"]) + + assert :ok = + notify( + client, + %{ + method: "textDocument/didOpen", + jsonrpc: "2.0", + params: %{ + textDocument: %{ + uri: file_uri, + languageId: "elixir", + version: 1, + text: "" + } + } + } + ) + + expected_message = "Started project node for #{Project.name(secondary_project)}" + + assert_notification( + "window/logMessage", + %{ + "type" => 3, + "message" => ^expected_message + } + ) + + assert [_main, project] = Expert.ActiveProjects.projects() + assert project.root_uri == secondary_project.root_uri + assert project_alive?(project) + end + end +end diff --git a/apps/forge/test/fixtures/workspace_folders/main/.formatter.exs b/apps/forge/test/fixtures/workspace_folders/main/.formatter.exs new file mode 100644 index 00000000..d2cda26e --- /dev/null +++ b/apps/forge/test/fixtures/workspace_folders/main/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/apps/forge/test/fixtures/workspace_folders/main/.gitignore b/apps/forge/test/fixtures/workspace_folders/main/.gitignore new file mode 100644 index 00000000..a97f1b11 --- /dev/null +++ b/apps/forge/test/fixtures/workspace_folders/main/.gitignore @@ -0,0 +1,23 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +main-*.tar + +# Temporary files, for example, from tests. +/tmp/ diff --git a/apps/forge/test/fixtures/workspace_folders/main/README.md b/apps/forge/test/fixtures/workspace_folders/main/README.md new file mode 100644 index 00000000..985c4638 --- /dev/null +++ b/apps/forge/test/fixtures/workspace_folders/main/README.md @@ -0,0 +1,21 @@ +# Main + +**TODO: Add description** + +## Installation + +If [available in Hex](https://hex.pm/docs/publish), the package can be installed +by adding `main` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:main, "~> 0.1.0"} + ] +end +``` + +Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) +and published on [HexDocs](https://hexdocs.pm). Once published, the docs can +be found at . + diff --git a/apps/forge/test/fixtures/workspace_folders/main/lib/main.ex b/apps/forge/test/fixtures/workspace_folders/main/lib/main.ex new file mode 100644 index 00000000..b85d55b6 --- /dev/null +++ b/apps/forge/test/fixtures/workspace_folders/main/lib/main.ex @@ -0,0 +1,18 @@ +defmodule Main do + @moduledoc """ + Documentation for `Main`. + """ + + @doc """ + Hello world. + + ## Examples + + iex> Main.hello() + :world + + """ + def hello do + :world + end +end diff --git a/apps/forge/test/fixtures/workspace_folders/main/mix.exs b/apps/forge/test/fixtures/workspace_folders/main/mix.exs new file mode 100644 index 00000000..3c6c8217 --- /dev/null +++ b/apps/forge/test/fixtures/workspace_folders/main/mix.exs @@ -0,0 +1,30 @@ +defmodule Main.MixProject do + use Mix.Project + + def project do + Code.put_compiler_option(:ignore_module_conflict, true) + + [ + app: :main, + version: "0.1.0", + elixir: "~> 1.18", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger] + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + # {:dep_from_hexpm, "~> 0.3.0"}, + # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} + ] + end +end diff --git a/apps/forge/test/fixtures/workspace_folders/main/test/main_test.exs b/apps/forge/test/fixtures/workspace_folders/main/test/main_test.exs new file mode 100644 index 00000000..0c1db163 --- /dev/null +++ b/apps/forge/test/fixtures/workspace_folders/main/test/main_test.exs @@ -0,0 +1,8 @@ +defmodule MainTest do + use ExUnit.Case + doctest Main + + test "greets the world" do + assert Main.hello() == :world + end +end diff --git a/apps/forge/test/fixtures/workspace_folders/main/test/test_helper.exs b/apps/forge/test/fixtures/workspace_folders/main/test/test_helper.exs new file mode 100644 index 00000000..869559e7 --- /dev/null +++ b/apps/forge/test/fixtures/workspace_folders/main/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start() diff --git a/apps/forge/test/fixtures/workspace_folders/secondary/.formatter.exs b/apps/forge/test/fixtures/workspace_folders/secondary/.formatter.exs new file mode 100644 index 00000000..d2cda26e --- /dev/null +++ b/apps/forge/test/fixtures/workspace_folders/secondary/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/apps/forge/test/fixtures/workspace_folders/secondary/.gitignore b/apps/forge/test/fixtures/workspace_folders/secondary/.gitignore new file mode 100644 index 00000000..7a591d24 --- /dev/null +++ b/apps/forge/test/fixtures/workspace_folders/secondary/.gitignore @@ -0,0 +1,23 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +secondary-*.tar + +# Temporary files, for example, from tests. +/tmp/ diff --git a/apps/forge/test/fixtures/workspace_folders/secondary/README.md b/apps/forge/test/fixtures/workspace_folders/secondary/README.md new file mode 100644 index 00000000..9956c991 --- /dev/null +++ b/apps/forge/test/fixtures/workspace_folders/secondary/README.md @@ -0,0 +1,21 @@ +# Secondary + +**TODO: Add description** + +## Installation + +If [available in Hex](https://hex.pm/docs/publish), the package can be installed +by adding `secondary` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:secondary, "~> 0.1.0"} + ] +end +``` + +Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) +and published on [HexDocs](https://hexdocs.pm). Once published, the docs can +be found at . + diff --git a/apps/forge/test/fixtures/workspace_folders/secondary/lib/secondary.ex b/apps/forge/test/fixtures/workspace_folders/secondary/lib/secondary.ex new file mode 100644 index 00000000..536cc192 --- /dev/null +++ b/apps/forge/test/fixtures/workspace_folders/secondary/lib/secondary.ex @@ -0,0 +1,18 @@ +defmodule Secondary do + @moduledoc """ + Documentation for `Secondary`. + """ + + @doc """ + Hello world. + + ## Examples + + iex> Secondary.hello() + :world + + """ + def hello do + :world + end +end diff --git a/apps/forge/test/fixtures/workspace_folders/secondary/mix.exs b/apps/forge/test/fixtures/workspace_folders/secondary/mix.exs new file mode 100644 index 00000000..1ca4083a --- /dev/null +++ b/apps/forge/test/fixtures/workspace_folders/secondary/mix.exs @@ -0,0 +1,30 @@ +defmodule Secondary.MixProject do + use Mix.Project + + def project do + Code.put_compiler_option(:ignore_module_conflict, true) + + [ + app: :secondary, + version: "0.1.0", + elixir: "~> 1.18", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger] + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + # {:dep_from_hexpm, "~> 0.3.0"}, + # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} + ] + end +end diff --git a/apps/forge/test/fixtures/workspace_folders/secondary/test/secondary_test.exs b/apps/forge/test/fixtures/workspace_folders/secondary/test/secondary_test.exs new file mode 100644 index 00000000..93d26dd8 --- /dev/null +++ b/apps/forge/test/fixtures/workspace_folders/secondary/test/secondary_test.exs @@ -0,0 +1,8 @@ +defmodule SecondaryTest do + use ExUnit.Case + doctest Secondary + + test "greets the world" do + assert Secondary.hello() == :world + end +end diff --git a/apps/forge/test/fixtures/workspace_folders/secondary/test/test_helper.exs b/apps/forge/test/fixtures/workspace_folders/secondary/test/test_helper.exs new file mode 100644 index 00000000..869559e7 --- /dev/null +++ b/apps/forge/test/fixtures/workspace_folders/secondary/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start() From d6279f4dc32ef88e8505e095c111ced53457eb29 Mon Sep 17 00:00:00 2001 From: doorgan Date: Wed, 9 Jul 2025 18:10:42 -0300 Subject: [PATCH 09/17] fix: use GenLSP logging utility --- apps/expert/lib/expert/state.ex | 28 ++++---------------- apps/expert/test/expert/expert_test.exs | 35 +++++-------------------- 2 files changed, 12 insertions(+), 51 deletions(-) diff --git a/apps/expert/lib/expert/state.ex b/apps/expert/lib/expert/state.ex index 11850269..b3eb6b26 100644 --- a/apps/expert/lib/expert/state.ex +++ b/apps/expert/lib/expert/state.ex @@ -129,14 +129,9 @@ defmodule Expert.State do Expert.Project.Supervisor.stop(project) - GenLSP.notify( + GenLSP.log( Expert.get_lsp(), - %GenLSP.Notifications.WindowLogMessage{ - params: %GenLSP.Structures.LogMessageParams{ - type: GenLSP.Enumerations.MessageType.info(), - message: "Stopping project node for #{Project.name(project)}" - } - } + "Stopping project node for #{Project.name(project)}" ) project @@ -278,15 +273,7 @@ defmodule Expert.State do {:ok, _pid} -> Logger.info("Project node started for #{Project.name(project)}") - GenLSP.notify( - Expert.get_lsp(), - %GenLSP.Notifications.WindowLogMessage{ - params: %GenLSP.Structures.LogMessageParams{ - type: GenLSP.Enumerations.MessageType.info(), - message: "Started project node for #{Project.name(project)}" - } - } - ) + GenLSP.log(Expert.get_lsp(), "Started project node for #{Project.name(project)}") {:error, {reason, pid}} when reason in [:already_started, :already_present] -> {:ok, pid} @@ -296,14 +283,9 @@ defmodule Expert.State do "Failed to start project node for #{Project.name(project)}: #{inspect(reason, pretty: true)}" ) - GenLSP.notify( + GenLSP.log( Expert.get_lsp(), - %GenLSP.Notifications.WindowLogMessage{ - params: %GenLSP.Structures.LogMessageParams{ - type: GenLSP.Enumerations.MessageType.error(), - message: "Failed to start project node for #{Project.name(project)}" - } - } + "Failed to start project node for #{Project.name(project)}" ) {:error, reason} diff --git a/apps/expert/test/expert/expert_test.exs b/apps/expert/test/expert/expert_test.exs index d8a1843f..7de79da3 100644 --- a/apps/expert/test/expert/expert_test.exs +++ b/apps/expert/test/expert/expert_test.exs @@ -94,10 +94,7 @@ defmodule ExpertTest do assert_notification( "window/logMessage", - %{ - "type" => 3, - "message" => ^expected_message - } + %{"message" => ^expected_message} ) assert [project] = Expert.ActiveProjects.projects() @@ -126,10 +123,7 @@ defmodule ExpertTest do assert_notification( "window/logMessage", - %{ - "type" => 3, - "message" => ^expected_message - } + %{"message" => ^expected_message} ) assert [_project_1] = Expert.ActiveProjects.projects() @@ -155,10 +149,7 @@ defmodule ExpertTest do assert_notification( "window/logMessage", - %{ - "type" => 3, - "message" => ^expected_message - } + %{"message" => ^expected_message} ) assert [project_1, project_2] = Expert.ActiveProjects.projects() @@ -185,10 +176,7 @@ defmodule ExpertTest do assert_notification( "window/logMessage", - %{ - "type" => 3, - "message" => ^expected_message - } + %{"message" => ^expected_message} ) assert [project] = Expert.ActiveProjects.projects() @@ -216,10 +204,7 @@ defmodule ExpertTest do assert_notification( "window/logMessage", - %{ - "type" => 3, - "message" => ^expected_message - } + %{"message" => ^expected_message} ) assert [] = Expert.ActiveProjects.projects() @@ -245,10 +230,7 @@ defmodule ExpertTest do assert_notification( "window/logMessage", - %{ - "type" => 3, - "message" => ^expected_message - } + %{"message" => ^expected_message} ) file_uri = Path.join([secondary_project.root_uri, "lib", "secondary.ex"]) @@ -274,10 +256,7 @@ defmodule ExpertTest do assert_notification( "window/logMessage", - %{ - "type" => 3, - "message" => ^expected_message - } + %{"message" => ^expected_message} ) assert [_main, project] = Expert.ActiveProjects.projects() From e25b98bd98e7f7c6ba67f2dd23b26b0fbe472c7c Mon Sep 17 00:00:00 2001 From: doorgan Date: Wed, 9 Jul 2025 19:23:02 -0300 Subject: [PATCH 10/17] chore: remove leftover code --- apps/expert/lib/expert/state.ex | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/expert/lib/expert/state.ex b/apps/expert/lib/expert/state.ex index b3eb6b26..c556e650 100644 --- a/apps/expert/lib/expert/state.ex +++ b/apps/expert/lib/expert/state.ex @@ -19,8 +19,7 @@ defmodule Expert.State do defstruct configuration: nil, initialized?: false, shutdown_received?: false, - in_flight_requests: %{}, - workspace_folders: [] + in_flight_requests: %{} @supported_code_actions [ Enumerations.CodeActionKind.quick_fix(), @@ -121,8 +120,6 @@ defmodule Expert.State do event: %Structures.WorkspaceFoldersChangeEvent{added: added, removed: removed} } }) do - workspace_folders = Enum.uniq((added ++ state.workspace_folders) -- removed) - removed_projects = for %{uri: uri} <- removed do project = Project.new(uri) @@ -147,8 +144,6 @@ defmodule Expert.State do ActiveProjects.add_projects(added_projects) ActiveProjects.remove_projects(removed_projects) - state = %__MODULE__{state | workspace_folders: workspace_folders} - {:ok, state} end From 3fa0ea46d3ceb8a7721aa61c93a662b4bf638a72 Mon Sep 17 00:00:00 2001 From: doorgan Date: Mon, 14 Jul 2025 13:51:42 -0300 Subject: [PATCH 11/17] chore: remove tests from new fixture projects --- .../fixtures/workspace_folders/main/test/main_test.exs | 8 -------- .../fixtures/workspace_folders/main/test/test_helper.exs | 1 - .../workspace_folders/secondary/test/secondary_test.exs | 8 -------- .../workspace_folders/secondary/test/test_helper.exs | 1 - 4 files changed, 18 deletions(-) delete mode 100644 apps/forge/test/fixtures/workspace_folders/main/test/main_test.exs delete mode 100644 apps/forge/test/fixtures/workspace_folders/main/test/test_helper.exs delete mode 100644 apps/forge/test/fixtures/workspace_folders/secondary/test/secondary_test.exs delete mode 100644 apps/forge/test/fixtures/workspace_folders/secondary/test/test_helper.exs diff --git a/apps/forge/test/fixtures/workspace_folders/main/test/main_test.exs b/apps/forge/test/fixtures/workspace_folders/main/test/main_test.exs deleted file mode 100644 index 0c1db163..00000000 --- a/apps/forge/test/fixtures/workspace_folders/main/test/main_test.exs +++ /dev/null @@ -1,8 +0,0 @@ -defmodule MainTest do - use ExUnit.Case - doctest Main - - test "greets the world" do - assert Main.hello() == :world - end -end diff --git a/apps/forge/test/fixtures/workspace_folders/main/test/test_helper.exs b/apps/forge/test/fixtures/workspace_folders/main/test/test_helper.exs deleted file mode 100644 index 869559e7..00000000 --- a/apps/forge/test/fixtures/workspace_folders/main/test/test_helper.exs +++ /dev/null @@ -1 +0,0 @@ -ExUnit.start() diff --git a/apps/forge/test/fixtures/workspace_folders/secondary/test/secondary_test.exs b/apps/forge/test/fixtures/workspace_folders/secondary/test/secondary_test.exs deleted file mode 100644 index 93d26dd8..00000000 --- a/apps/forge/test/fixtures/workspace_folders/secondary/test/secondary_test.exs +++ /dev/null @@ -1,8 +0,0 @@ -defmodule SecondaryTest do - use ExUnit.Case - doctest Secondary - - test "greets the world" do - assert Secondary.hello() == :world - end -end diff --git a/apps/forge/test/fixtures/workspace_folders/secondary/test/test_helper.exs b/apps/forge/test/fixtures/workspace_folders/secondary/test/test_helper.exs deleted file mode 100644 index 869559e7..00000000 --- a/apps/forge/test/fixtures/workspace_folders/secondary/test/test_helper.exs +++ /dev/null @@ -1 +0,0 @@ -ExUnit.start() From a4370d5f9d8fab05e3efaf77651cc2b5bdf97b77 Mon Sep 17 00:00:00 2001 From: doorgan Date: Tue, 15 Jul 2025 15:40:13 -0300 Subject: [PATCH 12/17] fix: prevent tests from randomly not running --- apps/expert/lib/expert/state.ex | 16 ++++--- apps/expert/test/expert/expert_test.exs | 63 ++++++++++++++++++------- 2 files changed, 55 insertions(+), 24 deletions(-) diff --git a/apps/expert/lib/expert/state.ex b/apps/expert/lib/expert/state.ex index bf59a6ab..dfb35776 100644 --- a/apps/expert/lib/expert/state.ex +++ b/apps/expert/lib/expert/state.ex @@ -127,12 +127,7 @@ defmodule Expert.State do for %{uri: uri} <- removed do project = Project.new(uri) - Expert.Project.Supervisor.stop(project) - - GenLSP.log( - Expert.get_lsp(), - "Stopping project node for #{Project.name(project)}" - ) + stop_project_node(project) project end @@ -290,6 +285,15 @@ defmodule Expert.State do end end + defp stop_project_node(project) do + Expert.Project.Supervisor.stop(project) + + GenLSP.log( + Expert.get_lsp(), + "Stopping project node for #{Project.name(project)}" + ) + end + def initialize_result do sync_options = %GenLSP.Structures.TextDocumentSyncOptions{ diff --git a/apps/expert/test/expert/expert_test.exs b/apps/expert/test/expert/expert_test.exs index 7de79da3..797380f4 100644 --- a/apps/expert/test/expert/expert_test.exs +++ b/apps/expert/test/expert/expert_test.exs @@ -6,9 +6,13 @@ defmodule ExpertTest do import Forge.Test.Fixtures use ExUnit.Case, async: false + use Patch setup_all do start_supervised!({Document.Store, derive: [analysis: &Forge.Ast.analyze/1]}) + start_supervised!({Task.Supervisor, name: :expert_task_queue}) + start_supervised!({DynamicSupervisor, name: Expert.DynamicSupervisor}) + start_supervised!({DynamicSupervisor, Expert.Project.DynamicSupervisor.options()}) project_root = fixtures_path() |> Path.join("workspace_folders") @@ -28,9 +32,21 @@ defmodule ExpertTest do end setup do - start_supervised!({Task.Supervisor, name: :expert_task_queue}) - start_supervised!({DynamicSupervisor, name: Expert.DynamicSupervisor}) - start_supervised!({DynamicSupervisor, Expert.Project.DynamicSupervisor.options()}) + # NOTE(doorgan): repeatedly starting and stopping nodes in tests produces some + # erratic behavior where sometimes some tests won't run. This somewhat mitigates + # that. + test_pid = self() + + patch(Expert.Project.Supervisor, :start, fn project -> + send(test_pid, {:project_alive, project.root_uri}) + {:ok, nil} + end) + + patch(Expert.Project.Supervisor, :stop, fn project -> + send(test_pid, {:project_stopped, project.root_uri}) + :ok + end) + start_supervised!({Expert.ActiveProjects, []}) server = @@ -39,10 +55,10 @@ defmodule ExpertTest do dynamic_supervisor: Expert.DynamicSupervisor ) - Process.link(server.lsp) - client = client(server) + Process.sleep(100) + [server: server, client: client] end @@ -70,8 +86,14 @@ defmodule ExpertTest do } end - def project_alive?(project) do - project |> Expert.Project.Supervisor.name() |> Process.whereis() |> is_pid() + def assert_project_alive?(project) do + expected_uri = project.root_uri + assert_receive {:project_alive, ^expected_uri} + end + + def assert_project_stopped?(project) do + expected_uri = project.root_uri + assert_receive {:project_stopped, ^expected_uri} end describe "initialize request" do @@ -100,7 +122,7 @@ defmodule ExpertTest do assert [project] = Expert.ActiveProjects.projects() assert project.root_uri == main_project.root_uri - assert project_alive?(main_project) + assert_project_alive?(main_project) end end @@ -152,12 +174,12 @@ defmodule ExpertTest do %{"message" => ^expected_message} ) - assert [project_1, project_2] = Expert.ActiveProjects.projects() - assert project_1.root_uri == main_project.root_uri - assert project_alive?(main_project) + assert [_, _] = projects = Expert.ActiveProjects.projects() - assert project_2.root_uri == secondary_project.root_uri - assert project_alive?(secondary_project) + for project <- projects do + assert project.root_uri in [main_project.root_uri, secondary_project.root_uri] + assert_project_alive?(project) + end end test "can remove workspace folders", %{ @@ -181,7 +203,7 @@ defmodule ExpertTest do assert [project] = Expert.ActiveProjects.projects() assert project.root_uri == main_project.root_uri - assert project_alive?(main_project) + assert_project_alive?(main_project) assert :ok = notify( @@ -208,7 +230,7 @@ defmodule ExpertTest do ) assert [] = Expert.ActiveProjects.projects() - refute project_alive?(main_project) + assert_project_stopped?(main_project) end end @@ -226,6 +248,8 @@ defmodule ExpertTest do initialize_request(project_root, id: 1, projects: [main_project]) ) + assert_result(1, _) + expected_message = "Started project node for #{Project.name(main_project)}" assert_notification( @@ -259,9 +283,12 @@ defmodule ExpertTest do %{"message" => ^expected_message} ) - assert [_main, project] = Expert.ActiveProjects.projects() - assert project.root_uri == secondary_project.root_uri - assert project_alive?(project) + assert [_, _] = projects = Expert.ActiveProjects.projects() + + for project <- projects do + assert project.root_uri in [main_project.root_uri, secondary_project.root_uri] + assert_project_alive?(project) + end end end end From 88e884bfedf8feb9ce58c13a7c21364cc9af102d Mon Sep 17 00:00:00 2001 From: doorgan Date: Tue, 15 Jul 2025 15:41:18 -0300 Subject: [PATCH 13/17] fix: fix flaky test This test was failing because the context project's entropy did not match what the patched function received. Matching on the uri instead(which is what actually identifies the project) fixes the test. --- .../test/expert/provider/handlers/find_references_test.exs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/expert/test/expert/provider/handlers/find_references_test.exs b/apps/expert/test/expert/provider/handlers/find_references_test.exs index 3764937c..1a9a6e56 100644 --- a/apps/expert/test/expert/provider/handlers/find_references_test.exs +++ b/apps/expert/test/expert/provider/handlers/find_references_test.exs @@ -53,7 +53,12 @@ defmodule Expert.Provider.Handlers.FindReferencesTest do describe "find references" do test "returns locations that the entity returns", %{project: project, uri: uri} do - patch(EngineApi, :references, fn ^project, %Analysis{document: document}, _position, _ -> + project_uri = project.root_uri + + patch(EngineApi, :references, fn %{root_uri: ^project_uri}, + %Analysis{document: document}, + _position, + _ -> locations = [ Location.new( Document.Range.new( From 1cf3bd9a5a70e01e085a65a6cfabc4f1e0169ae2 Mon Sep 17 00:00:00 2001 From: doorgan Date: Wed, 20 Aug 2025 22:07:37 -0300 Subject: [PATCH 14/17] fix: fallback to heuristics if workspace_folders is not set --- apps/expert/lib/expert/state.ex | 41 +++++++++++++++++++++++-- apps/expert/test/expert/expert_test.exs | 39 ++++++++++++++++++++--- 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/apps/expert/lib/expert/state.ex b/apps/expert/lib/expert/state.ex index dfb35776..19703393 100644 --- a/apps/expert/lib/expert/state.ex +++ b/apps/expert/lib/expert/state.ex @@ -63,10 +63,17 @@ defmodule Expert.State do response = initialize_result() projects = - for %{uri: uri} <- event.workspace_folders do - Project.new(uri) + case event.workspace_folders do + nil -> + find_projects(event.root_uri) + + _ -> + for %{uri: uri} <- event.workspace_folders, + project = Project.new(uri), + project.mix_project? do + project + end end - |> Enum.filter(& &1.mix_project?) ActiveProjects.set_projects(projects) @@ -89,6 +96,34 @@ defmodule Expert.State do Configuration.default(config) end + defp find_projects(root_uri) do + root_path = Forge.Document.Path.from_uri(root_uri) + root_mix_exs = Path.join(root_path, "mix.exs") + + projects = + if File.exists?(root_mix_exs) do + [Project.new(root_uri)] + else + find_multiroot_projects(root_path) + end + + if projects == [], do: [Project.new(root_uri)], else: projects + end + + defp find_multiroot_projects(root_path) do + mix_exs_blob = Path.join([root_path, "**", "mix.exs"]) + + for mix_exs_path <- Path.wildcard(mix_exs_blob), + "deps" not in Path.split(mix_exs_path) do + project_uri = + mix_exs_path + |> Path.dirname() + |> Forge.Document.Path.to_uri() + + Project.new(project_uri) + end + end + def apply(%__MODULE__{initialized?: false}, request) do Logger.error("Received #{request.method} before server was initialized") {:error, :not_initialized} diff --git a/apps/expert/test/expert/expert_test.exs b/apps/expert/test/expert/expert_test.exs index 797380f4..054799c2 100644 --- a/apps/expert/test/expert/expert_test.exs +++ b/apps/expert/test/expert/expert_test.exs @@ -64,7 +64,14 @@ defmodule ExpertTest do def initialize_request(root_path, opts \\ []) do id = opts[:id] || 1 - projects = opts[:projects] || [] + projects = Keyword.get(opts, :projects, []) + + workspace_folders = + if not is_nil(projects) do + Enum.map(projects, fn project -> + %{uri: project.root_uri, name: Project.name(project)} + end) + end %{ method: "initialize", @@ -78,10 +85,7 @@ defmodule ExpertTest do workspaceFolders: true } }, - workspaceFolders: - Enum.map(projects, fn project -> - %{uri: project.root_uri, name: Project.name(project)} - end) + workspaceFolders: workspace_folders } } end @@ -232,6 +236,31 @@ defmodule ExpertTest do assert [] = Expert.ActiveProjects.projects() assert_project_stopped?(main_project) end + + test "supports missing workspace_folders in the request", %{ + client: client, + project_root: project_root, + main_project: main_project, + secondary_project: secondary_project + } do + assert :ok = + request( + client, + initialize_request(project_root, id: 1, projects: nil) + ) + + assert_result(1, %{ + "capabilities" => %{"workspace" => %{"workspaceFolders" => %{"supported" => true}}} + }) + + assert [_, _] = projects = Expert.ActiveProjects.projects() + + for project <- projects do + assert project.root_uri in [main_project.root_uri, secondary_project.root_uri] + + assert_project_alive?(project) + end + end end describe "opening files" do From 04968a192e347e6095179596341c02c9a3378fcf Mon Sep 17 00:00:00 2001 From: doorgan Date: Wed, 20 Aug 2025 22:22:03 -0300 Subject: [PATCH 15/17] fix: remove heuristics and just default to no workspace folders --- apps/expert/lib/expert/state.ex | 46 ++++--------------------- apps/expert/test/expert/expert_test.exs | 12 ++----- 2 files changed, 8 insertions(+), 50 deletions(-) diff --git a/apps/expert/lib/expert/state.ex b/apps/expert/lib/expert/state.ex index 19703393..1f322452 100644 --- a/apps/expert/lib/expert/state.ex +++ b/apps/expert/lib/expert/state.ex @@ -63,16 +63,10 @@ defmodule Expert.State do response = initialize_result() projects = - case event.workspace_folders do - nil -> - find_projects(event.root_uri) - - _ -> - for %{uri: uri} <- event.workspace_folders, - project = Project.new(uri), - project.mix_project? do - project - end + for %{uri: uri} <- event.workspace_folders || [], + project = Project.new(uri), + project.mix_project? do + project end ActiveProjects.set_projects(projects) @@ -96,34 +90,6 @@ defmodule Expert.State do Configuration.default(config) end - defp find_projects(root_uri) do - root_path = Forge.Document.Path.from_uri(root_uri) - root_mix_exs = Path.join(root_path, "mix.exs") - - projects = - if File.exists?(root_mix_exs) do - [Project.new(root_uri)] - else - find_multiroot_projects(root_path) - end - - if projects == [], do: [Project.new(root_uri)], else: projects - end - - defp find_multiroot_projects(root_path) do - mix_exs_blob = Path.join([root_path, "**", "mix.exs"]) - - for mix_exs_path <- Path.wildcard(mix_exs_blob), - "deps" not in Path.split(mix_exs_path) do - project_uri = - mix_exs_path - |> Path.dirname() - |> Forge.Document.Path.to_uri() - - Project.new(project_uri) - end - end - def apply(%__MODULE__{initialized?: false}, request) do Logger.error("Received #{request.method} before server was initialized") {:error, :not_initialized} @@ -257,7 +223,7 @@ defmodule Expert.State do def apply(%__MODULE__{} = state, %GenLSP.Notifications.TextDocumentDidSave{params: params}) do uri = params.text_document.uri - project = Forge.Project.project_for_uri(state.configuration.projects, uri) + project = Forge.Project.project_for_uri(ActiveProjects.projects(), uri) case Document.Store.save(uri) do :ok -> @@ -282,7 +248,7 @@ defmodule Expert.State do end def apply(%__MODULE__{} = state, %Notifications.WorkspaceDidChangeWatchedFiles{params: params}) do - for project <- state.configuration.projects, + for project <- ActiveProjects.projects(), change <- params.changes do params = filesystem_event(project: Project, uri: change.uri, event_type: change.type) EngineApi.broadcast(project, params) diff --git a/apps/expert/test/expert/expert_test.exs b/apps/expert/test/expert/expert_test.exs index 054799c2..5034f29d 100644 --- a/apps/expert/test/expert/expert_test.exs +++ b/apps/expert/test/expert/expert_test.exs @@ -239,9 +239,7 @@ defmodule ExpertTest do test "supports missing workspace_folders in the request", %{ client: client, - project_root: project_root, - main_project: main_project, - secondary_project: secondary_project + project_root: project_root } do assert :ok = request( @@ -253,13 +251,7 @@ defmodule ExpertTest do "capabilities" => %{"workspace" => %{"workspaceFolders" => %{"supported" => true}}} }) - assert [_, _] = projects = Expert.ActiveProjects.projects() - - for project <- projects do - assert project.root_uri in [main_project.root_uri, secondary_project.root_uri] - - assert_project_alive?(project) - end + assert [] = Expert.ActiveProjects.projects() end end From 7e342cb2fb80b3a2ec6738fe987534cf86d9e67f Mon Sep 17 00:00:00 2001 From: doorgan Date: Thu, 21 Aug 2025 12:44:53 -0300 Subject: [PATCH 16/17] fix: try to start projects in a task --- apps/expert/lib/expert/state.ex | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/expert/lib/expert/state.ex b/apps/expert/lib/expert/state.ex index 1f322452..fe780906 100644 --- a/apps/expert/lib/expert/state.ex +++ b/apps/expert/lib/expert/state.ex @@ -191,7 +191,9 @@ defmodule Expert.State do end if project do - ensure_project_node_started(project) + Task.Supervisor.start_child(:expert_task_queue, fn -> + ensure_project_node_started(project) + end) ActiveProjects.add_projects([project]) end From a3201f247f538a921326e9437828314417dd7737 Mon Sep 17 00:00:00 2001 From: doorgan Date: Thu, 21 Aug 2025 13:02:38 -0300 Subject: [PATCH 17/17] chore: format --- apps/expert/lib/expert/state.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/expert/lib/expert/state.ex b/apps/expert/lib/expert/state.ex index fe780906..6d0ec2f8 100644 --- a/apps/expert/lib/expert/state.ex +++ b/apps/expert/lib/expert/state.ex @@ -194,6 +194,7 @@ defmodule Expert.State do Task.Supervisor.start_child(:expert_task_queue, fn -> ensure_project_node_started(project) end) + ActiveProjects.add_projects([project]) end