diff --git a/apps/expert/config/runtime.exs b/apps/expert/config/runtime.exs index 71f77d68..f888c3b9 100644 --- a/apps/expert/config/runtime.exs +++ b/apps/expert/config/runtime.exs @@ -20,5 +20,3 @@ if Code.ensure_loaded?(LoggerFileBackend) do else :ok end - -require Logger diff --git a/apps/expert/lib/expert.ex b/apps/expert/lib/expert.ex index 8da8c1d6..a4c258a9 100644 --- a/apps/expert/lib/expert.ex +++ b/apps/expert/lib/expert.ex @@ -14,6 +14,7 @@ defmodule Expert do GenLSP.Notifications.TextDocumentDidChange, GenLSP.Notifications.WorkspaceDidChangeConfiguration, GenLSP.Notifications.WorkspaceDidChangeWatchedFiles, + GenLSP.Notifications.WorkspaceDidChangeWorkspaceFolders, GenLSP.Notifications.TextDocumentDidClose, GenLSP.Notifications.TextDocumentDidOpen, GenLSP.Notifications.TextDocumentDidSave, @@ -121,6 +122,7 @@ defmodule Expert do end def handle_notification(%GenLSP.Notifications.Initialized{}, lsp) do + Logger.info("Server initialized, registering capabilities") registrations = registrations() if nil != GenLSP.request(lsp, registrations) do 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 94f639dc..ecbeae24 100644 --- a/apps/expert/lib/expert/application.ex +++ b/apps/expert/lib/expert/application.ex @@ -19,6 +19,7 @@ defmodule Expert.Application do {GenLSP.Assigns, [name: Expert.Assigns]}, {Task.Supervisor, name: :expert_task_queue}, {GenLSP.Buffer, name: Expert.Buffer}, + {Expert.ActiveProjects, []}, {Expert, name: Expert, buffer: Expert.Buffer, diff --git a/apps/expert/lib/expert/configuration.ex b/apps/expert/lib/expert/configuration.ex index 9322bc49..7ee3567e 100644 --- a/apps/expert/lib/expert/configuration.ex +++ b/apps/expert/lib/expert/configuration.ex @@ -6,19 +6,16 @@ defmodule Expert.Configuration do alias Expert.Configuration.Support alias Expert.Dialyzer alias Expert.Protocol.Id - alias Forge.Project alias GenLSP.Notifications.WorkspaceDidChangeConfiguration alias GenLSP.Requests alias GenLSP.Structures - defstruct project: nil, - support: nil, + defstruct support: nil, client_name: nil, additional_watched_extensions: nil, dialyzer_enabled?: false @type t :: %__MODULE__{ - project: Project.t() | nil, support: support | nil, client_name: String.t() | nil, additional_watched_extensions: [String.t()] | nil, @@ -29,12 +26,11 @@ defmodule Expert.Configuration do @dialyzer {:nowarn_function, set_dialyzer_enabled: 2} - @spec new(Forge.uri(), map(), String.t() | nil) :: t - def new(root_uri, %Structures.ClientCapabilities{} = client_capabilities, client_name) do + @spec new(Structures.ClientCapabilities.t(), String.t() | nil) :: t + def new(%Structures.ClientCapabilities{} = client_capabilities, client_name) do support = Support.new(client_capabilities) - project = Project.new(root_uri) - %__MODULE__{support: support, project: project, client_name: client_name} + %__MODULE__{support: support, client_name: client_name} |> tap(&set/1) end diff --git a/apps/expert/lib/expert/engine_supervisor.ex b/apps/expert/lib/expert/engine_supervisor.ex index 680b40d5..30d3511f 100644 --- a/apps/expert/lib/expert/engine_supervisor.ex +++ b/apps/expert/lib/expert/engine_supervisor.ex @@ -14,11 +14,15 @@ defmodule Expert.EngineSupervisor do end def start_link(%Project{} = project) do - DynamicSupervisor.start_link(__MODULE__, project, name: __MODULE__, strategy: :one_for_one) + DynamicSupervisor.start_link(__MODULE__, project, name: name(project), strategy: :one_for_one) + end + + defp name(%Project{} = project) do + :"#{Project.name(project)}::project_node_supervisor" end def start_project_node(%Project{} = project) do - DynamicSupervisor.start_child(__MODULE__, EngineNode.child_spec(project)) + DynamicSupervisor.start_child(name(project), EngineNode.child_spec(project)) end @impl true diff --git a/apps/expert/lib/expert/project/supervisor.ex b/apps/expert/lib/expert/project/supervisor.ex index d23d6d65..009bcc8b 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 @@ -49,7 +39,7 @@ defmodule Expert.Project.Supervisor do DynamicSupervisor.terminate_child(Expert.Project.DynamicSupervisor.name(), pid) end - defp name(%Project{} = project) do + def name(%Project{} = project) do :"#{Project.name(project)}::supervisor" end end diff --git a/apps/expert/lib/expert/provider/handlers/code_action.ex b/apps/expert/lib/expert/provider/handlers/code_action.ex index 2a4efffa..7893692b 100644 --- a/apps/expert/lib/expert/provider/handlers/code_action.ex +++ b/apps/expert/lib/expert/provider/handlers/code_action.ex @@ -1,20 +1,24 @@ defmodule Expert.Provider.Handlers.CodeAction do + alias Expert.ActiveProjects alias Expert.Configuration alias Expert.EngineApi alias Forge.CodeAction + alias Forge.Project alias GenLSP.Requests alias GenLSP.Structures def handle( %Requests.TextDocumentCodeAction{params: %Structures.CodeActionParams{} = params}, - %Configuration{} = config + %Configuration{} ) do document = Forge.Document.Container.context_document(params, nil) + projects = ActiveProjects.projects() + project = Project.project_for_document(projects, document) diagnostics = Enum.map(params.context.diagnostics, &to_code_action_diagnostic/1) code_actions = EngineApi.code_actions( - config.project, + project, document, params.range, diagnostics, diff --git a/apps/expert/lib/expert/provider/handlers/code_lens.ex b/apps/expert/lib/expert/provider/handlers/code_lens.ex index 62ccdd6e..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,12 +15,16 @@ 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) + projects = ActiveProjects.projects() + project = Project.project_for_document(projects, document) + + document = Document.Container.context_document(params, nil) lenses = - case reindex_lens(config.project, document) do + case reindex_lens(project, 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 d18836f2..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,13 +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 -> - Logger.info("Reindex #{Project.name(config.project)}") - reindex(config.project) + project_names = Enum.map_join(projects, ", ", &Project.name/1) + Logger.info("Reindex #{project_names}") + reindex_all(projects) invalid -> message = "#{invalid} is not a valid command" @@ -41,23 +45,25 @@ defmodule Expert.Provider.Handlers.Commands do {:reply, response} end - defp reindex(%Project{} = project) do - case EngineApi.reindex(project) do - :ok -> - {:ok, "ok"} + defp reindex_all(projects) do + Enum.reduce_while(projects, :ok, fn project, _ -> + case EngineApi.reindex(project) do + :ok -> + {:cont, "ok"} - error -> - GenLSP.notify(Expert.get_lsp(), %GenLSP.Notifications.WindowShowMessage{ - params: %GenLSP.Structures.ShowMessageParams{ - type: GenLSP.Enumerations.MessageType.error(), - message: "Indexing #{Project.name(project)} failed" - } - }) + error -> + GenLSP.notify(Expert.get_lsp(), %GenLSP.Notifications.WindowShowMessage{ + params: %GenLSP.Structures.ShowMessageParams{ + type: GenLSP.Enumerations.MessageType.error(), + message: "Indexing #{Project.name(project)} failed" + } + }) - Logger.error("Indexing command failed due to #{inspect(error)}") + Logger.error("Indexing command failed due to #{inspect(error)}") - {:ok, internal_error("Could not reindex: #{error}")} - end + {:halt, internal_error("Could not reindex: #{error}")} + end + end) end defp internal_error(message) do diff --git a/apps/expert/lib/expert/provider/handlers/completion.ex b/apps/expert/lib/expert/provider/handlers/completion.ex index 39f19618..32bea113 100644 --- a/apps/expert/lib/expert/provider/handlers/completion.ex +++ b/apps/expert/lib/expert/provider/handlers/completion.ex @@ -1,9 +1,11 @@ defmodule Expert.Provider.Handlers.Completion do + alias Expert.ActiveProjects alias Expert.CodeIntelligence alias Expert.Configuration alias Forge.Ast alias Forge.Document alias Forge.Document.Position + alias Forge.Project alias GenLSP.Enumerations.CompletionTriggerKind alias GenLSP.Requests alias GenLSP.Structures @@ -13,13 +15,15 @@ defmodule Expert.Provider.Handlers.Completion do %Requests.TextDocumentCompletion{ params: %Structures.CompletionParams{} = params }, - %Configuration{} = config + %Configuration{} ) do document = Document.Container.context_document(params, nil) + projects = ActiveProjects.projects() + project = Project.project_for_document(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 a026a97a..37945cac 100644 --- a/apps/expert/lib/expert/provider/handlers/document_symbols.ex +++ b/apps/expert/lib/expert/provider/handlers/document_symbols.ex @@ -1,17 +1,21 @@ defmodule Expert.Provider.Handlers.DocumentSymbols do + alias Expert.ActiveProjects alias Expert.Configuration alias Expert.EngineApi alias Forge.CodeIntelligence.Symbols alias Forge.Document + alias Forge.Project alias GenLSP.Enumerations.SymbolKind 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) + projects = ActiveProjects.projects() + project = Project.project_for_document(projects, document) symbols = - config.project + project |> EngineApi.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 d7318934..bd3654ef 100644 --- a/apps/expert/lib/expert/provider/handlers/find_references.ex +++ b/apps/expert/lib/expert/provider/handlers/find_references.ex @@ -1,8 +1,10 @@ defmodule Expert.Provider.Handlers.FindReferences do + alias Expert.ActiveProjects alias Expert.Configuration alias Expert.EngineApi alias Forge.Ast alias Forge.Document + alias Forge.Project alias GenLSP.Requests.TextDocumentReferences alias GenLSP.Structures @@ -10,15 +12,17 @@ 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) + projects = ActiveProjects.projects() + project = Project.project_for_document(projects, document) include_declaration? = !!params.context.include_declaration locations = case Document.Store.fetch(document.uri, :analysis) do {:ok, _document, %Ast.Analysis{} = analysis} -> - EngineApi.references(config.project, analysis, params.position, include_declaration?) + EngineApi.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 9d9f043a..3b853a86 100644 --- a/apps/expert/lib/expert/provider/handlers/formatting.ex +++ b/apps/expert/lib/expert/provider/handlers/formatting.ex @@ -1,7 +1,9 @@ defmodule Expert.Provider.Handlers.Formatting do + alias Expert.ActiveProjects alias Expert.Configuration alias Expert.EngineApi alias Forge.Document.Changes + alias Forge.Project alias GenLSP.Requests alias GenLSP.Structures @@ -9,11 +11,13 @@ 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) + projects = ActiveProjects.projects() + project = Project.project_for_document(projects, document) - case EngineApi.format(config.project, document) do + case EngineApi.format(project, document) do {:ok, %Changes{} = document_edits} -> {:ok, 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 b18e744d..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,6 +1,8 @@ defmodule Expert.Provider.Handlers.GoToDefinition do + alias Expert.ActiveProjects alias Expert.Configuration alias Expert.EngineApi + alias Forge.Project alias GenLSP.Requests alias GenLSP.Structures @@ -10,11 +12,13 @@ defmodule Expert.Provider.Handlers.GoToDefinition do %Requests.TextDocumentDefinition{ params: %Structures.DefinitionParams{} = params }, - %Configuration{} = config + %Configuration{} ) do document = Forge.Document.Container.context_document(params, nil) + projects = ActiveProjects.projects() + project = Project.project_for_document(projects, document) - case EngineApi.definition(config.project, document, params.position) do + case EngineApi.definition(project, document, params.position) do {:ok, native_location} -> {:ok, native_location} diff --git a/apps/expert/lib/expert/provider/handlers/hover.ex b/apps/expert/lib/expert/provider/handlers/hover.ex index ce43e9c7..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,15 +18,17 @@ defmodule Expert.Provider.Handlers.Hover do %Requests.TextDocumentHover{ params: %Structures.HoverParams{} = params }, - %Configuration{} = config + %Configuration{} ) do document = Document.Container.context_document(params, nil) + projects = ActiveProjects.projects() + project = Project.project_for_document(projects, 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/provider/handlers/workspace_symbol.ex b/apps/expert/lib/expert/provider/handlers/workspace_symbol.ex index e3154f23..fbf24765 100644 --- a/apps/expert/lib/expert/provider/handlers/workspace_symbol.ex +++ b/apps/expert/lib/expert/provider/handlers/workspace_symbol.ex @@ -1,7 +1,9 @@ defmodule Expert.Provider.Handlers.WorkspaceSymbol do + alias Expert.ActiveProjects alias Expert.Configuration alias Expert.EngineApi alias Forge.CodeIntelligence.Symbols + alias Forge.Project alias GenLSP.Enumerations.SymbolKind alias GenLSP.Requests alias GenLSP.Structures @@ -9,15 +11,14 @@ defmodule Expert.Provider.Handlers.WorkspaceSymbol do require Logger def handle( - %Requests.WorkspaceSymbol{params: %Structures.WorkspaceSymbolParams{} = params}, - %Configuration{} = config + %Requests.WorkspaceSymbol{params: %Structures.WorkspaceSymbolParams{} = params} = request, + %Configuration{} ) do + projects = ActiveProjects.projects() + symbols = if String.length(params.query) > 1 do - config.project - |> EngineApi.workspace_symbols(params.query) - |> tap(fn symbols -> Logger.info("syms #{inspect(Enum.take(symbols, 5))}") end) - |> Enum.map(&to_response/1) + Enum.flat_map(projects, &gather_symbols(&1, request)) else [] end @@ -27,7 +28,19 @@ defmodule Expert.Provider.Handlers.WorkspaceSymbol do {:ok, symbols} end - def to_response(%Symbols.Workspace{} = root) do + defp gather_symbols( + %Project{} = project, + %Requests.WorkspaceSymbol{ + params: %Structures.WorkspaceSymbolParams{} = params + } + ) do + project + |> EngineApi.workspace_symbols(params.query) + |> tap(fn symbols -> Logger.info("syms #{inspect(Enum.take(symbols, 5))}") end) + |> Enum.map(&to_lsp_symbol/1) + end + + def to_lsp_symbol(%Symbols.Workspace{} = root) do %Structures.WorkspaceSymbol{ kind: to_kind(root.type), location: to_location(root.link), diff --git a/apps/expert/lib/expert/state.ex b/apps/expert/lib/expert/state.ex index 927345b3..6d0ec2f8 100644 --- a/apps/expert/lib/expert/state.ex +++ b/apps/expert/lib/expert/state.ex @@ -1,10 +1,12 @@ defmodule Expert.State do + alias Expert.ActiveProjects alias Expert.CodeIntelligence alias Expert.Configuration alias Expert.EngineApi alias Expert.Project alias Expert.Provider.Handlers alias Forge.Document + alias Forge.Project alias GenLSP.Enumerations alias GenLSP.Notifications alias GenLSP.Requests @@ -49,14 +51,31 @@ defmodule Expert.State do _ -> nil end - config = Configuration.new(event.root_uri, event.capabilities, client_name) + root_path = Document.Path.from_uri(event.root_uri) + + root_path + |> Forge.Workspace.new() + |> Forge.Workspace.set_workspace() + + config = Configuration.new(event.capabilities, client_name) new_state = %__MODULE__{state | configuration: config, initialized?: true} - Logger.info("Starting project at uri #{config.project.root_uri}") response = initialize_result() + projects = + for %{uri: uri} <- event.workspace_folders || [], + project = Project.new(uri), + project.mix_project? do + project + end + + ActiveProjects.set_projects(projects) + Task.Supervisor.start_child(:expert_task_queue, fn -> - Project.Supervisor.start(config.project) + for project <- projects do + ensure_project_node_started(project) + end + send(Expert, :engine_initialized) end) @@ -100,10 +119,38 @@ defmodule Expert.State do {:ok, state} end + def apply(%__MODULE__{} = state, %Notifications.WorkspaceDidChangeWorkspaceFolders{ + params: %Structures.DidChangeWorkspaceFoldersParams{ + event: %Structures.WorkspaceFoldersChangeEvent{added: added, removed: removed} + } + }) do + removed_projects = + for %{uri: uri} <- removed do + project = Project.new(uri) + + stop_project_node(project) + + project + end + + added_projects = + for %{uri: uri} <- added do + project = Project.new(uri) + ensure_project_node_started(project) + project + end + + ActiveProjects.add_projects(added_projects) + ActiveProjects.remove_projects(removed_projects) + + {:ok, state} + end + def apply(%__MODULE__{} = state, %GenLSP.Notifications.TextDocumentDidChange{params: params}) do uri = params.text_document.uri version = params.text_document.version - project = state.configuration.project + projects = ActiveProjects.projects() + project = Project.project_for_uri(projects, uri) case Document.Store.get_and_update( uri, @@ -120,7 +167,7 @@ defmodule Expert.State do ) EngineApi.broadcast(project, updated_message) - EngineApi.compile_document(state.configuration.project, updated_source) + EngineApi.compile_document(project, updated_source) {:ok, state} error -> @@ -136,13 +183,27 @@ defmodule Expert.State do language_id: language_id } = did_open.params.text_document + config = state.configuration + + project = + with nil <- Enum.find(ActiveProjects.projects(), &Project.within_project?(&1, uri)) do + Project.find_project(uri) + end + + if project do + Task.Supervisor.start_child(:expert_task_queue, fn -> + ensure_project_node_started(project) + end) + + 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)}") + Logger.error("Could not open #{uri} #{inspect(error)}") error end end @@ -165,10 +226,11 @@ 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(ActiveProjects.projects(), uri) case Document.Store.save(uri) do :ok -> - EngineApi.schedule_compile(state.configuration.project, false) + EngineApi.schedule_compile(project, false) {:ok, state} error -> @@ -188,15 +250,12 @@ defmodule Expert.State do {:ok, nil, %__MODULE__{state | shutdown_received?: true}} end - def apply(%__MODULE__{} = state, %GenLSP.Notifications.WorkspaceDidChangeWatchedFiles{ - params: params - }) do - project = state.configuration.project - - Enum.each(params.changes, fn %GenLSP.Structures.FileEvent{} = change -> - event = filesystem_event(project: Project, uri: change.uri, event_type: change.type) - EngineApi.broadcast(project, event) - end) + def apply(%__MODULE__{} = state, %Notifications.WorkspaceDidChangeWatchedFiles{params: params}) do + for project <- ActiveProjects.projects(), + change <- params.changes do + params = filesystem_event(project: Project, uri: change.uri, event_type: change.type) + EngineApi.broadcast(project, params) + end {:ok, state} end @@ -206,6 +265,39 @@ defmodule Expert.State do {:ok, state} end + defp ensure_project_node_started(project) do + case Expert.Project.Supervisor.start(project) do + {:ok, _pid} -> + Logger.info("Project node started 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} + + {:error, reason} -> + Logger.error( + "Failed to start project node for #{Project.name(project)}: #{inspect(reason, pretty: true)}" + ) + + GenLSP.log( + Expert.get_lsp(), + "Failed to start project node for #{Project.name(project)}" + ) + + {:error, reason} + 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{ @@ -243,7 +335,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/expert/test/expert/expert_test.exs b/apps/expert/test/expert/expert_test.exs new file mode 100644 index 00000000..5034f29d --- /dev/null +++ b/apps/expert/test/expert/expert_test.exs @@ -0,0 +1,315 @@ +defmodule ExpertTest do + alias Forge.Document + alias Forge.Project + + import GenLSP.Test + 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") + + 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 + # 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 = + server(Expert, + task_supervisor: :expert_task_queue, + dynamic_supervisor: Expert.DynamicSupervisor + ) + + client = client(server) + + Process.sleep(100) + + [server: server, client: client] + end + + def initialize_request(root_path, opts \\ []) do + id = opts[:id] || 1 + 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", + id: id, + jsonrpc: "2.0", + params: %{ + rootUri: Document.Path.to_uri(root_path), + initializationOptions: %{}, + capabilities: %{ + workspace: %{ + workspaceFolders: true + } + }, + workspaceFolders: workspace_folders + } + } + end + + 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 + 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", + %{"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", + %{"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", + %{"message" => ^expected_message} + ) + + 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 + + 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", + %{"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", + %{"message" => ^expected_message} + ) + + assert [] = Expert.ActiveProjects.projects() + assert_project_stopped?(main_project) + end + + test "supports missing workspace_folders in the request", %{ + client: client, + project_root: project_root + } do + assert :ok = + request( + client, + initialize_request(project_root, id: 1, projects: nil) + ) + + assert_result(1, %{ + "capabilities" => %{"workspace" => %{"workspaceFolders" => %{"supported" => true}}} + }) + + assert [] = Expert.ActiveProjects.projects() + 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]) + ) + + assert_result(1, _) + + expected_message = "Started project node for #{Project.name(main_project)}" + + assert_notification( + "window/logMessage", + %{"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", + %{"message" => ^expected_message} + ) + + 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 diff --git a/apps/expert/test/expert/provider/handlers/code_action_test.exs b/apps/expert/test/expert/provider/handlers/code_action_test.exs index f8882475..712dd08b 100644 --- a/apps/expert/test/expert/provider/handlers/code_action_test.exs +++ b/apps/expert/test/expert/provider/handlers/code_action_test.exs @@ -17,6 +17,9 @@ defmodule Expert.Provider.Handlers.CodeActionTest do start_supervised!({DynamicSupervisor, Expert.Project.DynamicSupervisor.options()}) start_supervised!({Expert.Project.Supervisor, project}) + start_supervised!({Expert.ActiveProjects, []}) + + Expert.ActiveProjects.set_projects([project]) EngineApi.register_listener(project, self(), [project_compiled()]) EngineApi.schedule_compile(project, true) @@ -60,8 +63,8 @@ defmodule Expert.Provider.Handlers.CodeActionTest do end end - def handle(request, project) do - config = Expert.Configuration.new(project: project) + def handle(request, _project) do + config = Expert.Configuration.new() Handlers.CodeAction.handle(request, config) 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 d17cd605..2a6f1604 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(project: 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 697194b8..1a9a6e56 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,13 +46,19 @@ defmodule Expert.Provider.Handlers.FindReferencesTest do end def handle(request, project) do - config = Expert.Configuration.new(project: project) + Expert.ActiveProjects.add_projects([project]) + config = Expert.Configuration.new() Handlers.FindReferences.handle(request, config) end 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( 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 e46184cb..593fb5b7 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(project: 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 a4927e83..83fe65b7 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(project: project) + config = Expert.Configuration.new() Handlers.Hover.handle(request, config) end 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/namespace/transform/boots.ex b/apps/forge/lib/forge/namespace/transform/boots.ex index c66f5897..130ad57b 100644 --- a/apps/forge/lib/forge/namespace/transform/boots.ex +++ b/apps/forge/lib/forge/namespace/transform/boots.ex @@ -19,7 +19,7 @@ defmodule Forge.Namespace.Transform.Boots do end defp find_boot_files(base_directory) do - [base_directory, "releases", "**", "*.script"] + [base_directory, "**", "*.script"] |> Path.join() |> Path.wildcard() end diff --git a/apps/forge/lib/forge/path.ex b/apps/forge/lib/forge/path.ex new file mode 100644 index 00000000..f815dd38 --- /dev/null +++ b/apps/forge/lib/forge/path.ex @@ -0,0 +1,33 @@ +defmodule Forge.Path do + @moduledoc """ + Helpers for working with paths. + """ + + @doc """ + Checks if the `parent_path` is a parent directory 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) 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) + + String.starts_with?(normalized_child, normalized_parent) + end +end diff --git a/apps/forge/lib/forge/project.ex b/apps/forge/lib/forge/project.ex index 536bee8a..16086c87 100644 --- a/apps/forge/lib/forge/project.ex +++ b/apps/forge/lib/forge/project.ex @@ -164,7 +164,15 @@ defmodule Forge.Project do end def manager_node_name(%__MODULE__{} = project) do - :"manager-#{name(project)}-#{entropy(project)}@127.0.0.1" + workspace = Forge.Workspace.get_workspace() + + workspace_name = + case workspace do + nil -> name(project) + _ -> Forge.Workspace.name(workspace) + end + + :"manager-#{workspace_name}-#{entropy(project)}@127.0.0.1" end @doc """ @@ -345,4 +353,73 @@ 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 -> + Forge.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 -> + 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 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 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/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/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