diff --git a/apps/engine/.formatter.exs b/apps/engine/.formatter.exs index 4e725b36..f7547916 100644 --- a/apps/engine/.formatter.exs +++ b/apps/engine/.formatter.exs @@ -3,7 +3,7 @@ current_directory = Path.dirname(__ENV__.file) import_deps = [:forge] -locals_without_parens = [with_progress: 2, with_progress: 3, defkey: 2, defkey: 3, with_wal: 2] +locals_without_parens = [defkey: 2, defkey: 3, with_wal: 2] [ locals_without_parens: locals_without_parens, diff --git a/apps/engine/lib/engine.ex b/apps/engine/lib/engine.ex index afe3c4e8..643e636d 100644 --- a/apps/engine/lib/engine.ex +++ b/apps/engine/lib/engine.ex @@ -8,6 +8,7 @@ defmodule Engine do alias Engine.Api.Proxy alias Engine.CodeAction alias Engine.CodeIntelligence + alias Engine.Progress alias Forge.Project require Logger @@ -68,10 +69,12 @@ defmodule Engine do do: app end - def ensure_apps_started do + def ensure_apps_started(token \\ Progress.noop_token()) do apps_to_start = [:elixir, :runtime_tools | @allowed_apps] Enum.reduce_while(apps_to_start, :ok, fn app_name, _ -> + Progress.report(token, message: "Starting #{app_name}...") + case :application.ensure_all_started(app_name) do {:ok, _} -> {:cont, :ok} error -> {:halt, error} @@ -111,4 +114,12 @@ defmodule Engine do def set_project(%Project{} = project) do :persistent_term.put({__MODULE__, :project}, project) end + + def get_manager_node do + :persistent_term.get({__MODULE__, :manager_node}, nil) + end + + def set_manager_node(node) when is_atom(node) do + :persistent_term.put({__MODULE__, :manager_node}, node) + end end diff --git a/apps/engine/lib/engine/api/proxy.ex b/apps/engine/lib/engine/api/proxy.ex index 61dba9c1..ef36ce9c 100644 --- a/apps/engine/lib/engine/api/proxy.ex +++ b/apps/engine/lib/engine/api/proxy.ex @@ -12,8 +12,7 @@ defmodule Engine.Api.Proxy do The logic follows below `broadcast` - Buffered - Though, those related to other events, like compilation are subject to - the rules that govern their source events. Progress messages are sent regardless of - buffering. + the rules that govern their source events. `schedule_compile` - Buffered - Only one call is kept `compile_document` - Buffered, though only one call per URI is kept, and if a `schedule_compile` call was buffered, all `compile_document` calls are dropped @@ -39,9 +38,7 @@ defmodule Engine.Api.Proxy do alias Engine.Api.Proxy.Records alias Engine.CodeMod alias Engine.Commands - alias Forge.EngineApi.Messages - import Messages import Record import Records, only: :macros @@ -62,10 +59,6 @@ defmodule Engine.Api.Proxy do # proxied functions - def broadcast(percent_progress() = message) do - Engine.Dispatch.broadcast(message) - end - def broadcast(message) do mfa = to_mfa(Engine.Dispatch.broadcast(message)) :gen_statem.call(__MODULE__, buffer(contents: mfa)) diff --git a/apps/engine/lib/engine/application.ex b/apps/engine/lib/engine/application.ex index 395909f0..014aaf60 100644 --- a/apps/engine/lib/engine/application.ex +++ b/apps/engine/lib/engine/application.ex @@ -2,7 +2,6 @@ defmodule Engine.Application do @moduledoc false use Application - require Logger @impl true def start(_type, _args) do @@ -12,7 +11,7 @@ defmodule Engine.Application do Engine.Api.Proxy, Engine.Commands.Reindex, Engine.Module.Loader, - {Engine.Dispatch, progress: true}, + Engine.Dispatch, Engine.ModuleMappings, Engine.Build, Engine.Build.CaptureServer, diff --git a/apps/engine/lib/engine/bootstrap.ex b/apps/engine/lib/engine/bootstrap.ex index 89dbe3c4..25da8364 100644 --- a/apps/engine/lib/engine/bootstrap.ex +++ b/apps/engine/lib/engine/bootstrap.ex @@ -11,7 +11,7 @@ defmodule Engine.Bootstrap do require Logger - def init(%Project{} = project, document_store_entropy, app_configs) do + def init(%Project{} = project, document_store_entropy, app_configs, manager_node) do Forge.Document.Store.set_entropy(document_store_entropy) Application.put_all_env(app_configs) @@ -26,6 +26,7 @@ defmodule Engine.Bootstrap do {:ok, _} <- Application.ensure_all_started(:logger) do project = maybe_load_mix_exs(project) Engine.set_project(project) + Engine.set_manager_node(manager_node) Mix.env(:test) ExUnit.start() start_logger(project) diff --git a/apps/engine/lib/engine/build.ex b/apps/engine/lib/engine/build.ex index b9819a5a..03bb6c5d 100644 --- a/apps/engine/lib/engine/build.ex +++ b/apps/engine/lib/engine/build.ex @@ -1,9 +1,7 @@ defmodule Engine.Build do - alias Forge.Document - alias Forge.Project - alias Engine.Build.Document.Compilers.HEEx alias Engine.Build.State + alias Forge.{Document, Project} require Logger use GenServer @@ -35,9 +33,15 @@ defmodule Engine.Build do :ok end - def with_lock(func) do - Engine.with_lock(__MODULE__, func) - end + def with_lock(func), do: Engine.with_lock(__MODULE__, func) + + # can't pass work token to Tracer module, so store it in persistent term. + + def set_progress_token(token), do: :persistent_term.put({__MODULE__, :progress_token}, token) + + def get_progress_token, do: :persistent_term.get({__MODULE__, :progress_token}, nil) + + def clear_progress_token, do: :persistent_term.erase({__MODULE__, :progress_token}) # GenServer Callbacks diff --git a/apps/engine/lib/engine/build/project.ex b/apps/engine/lib/engine/build/project.ex index bf8d8eae..50384b6a 100644 --- a/apps/engine/lib/engine/build/project.ex +++ b/apps/engine/lib/engine/build/project.ex @@ -1,51 +1,60 @@ defmodule Engine.Build.Project do alias Forge.Project - - alias Engine.Build + alias Engine.{Build, Plugin, Progress} alias Engine.Build.Isolation - alias Engine.Plugin alias Mix.Task.Compiler.Diagnostic - use Engine.Progress require Logger def compile(%Project{} = project, initial?) do Engine.Mix.in_project(fn _ -> - Mix.Task.clear() - - prepare_for_project_build(initial?) + Logger.info("Building #{Project.display_name(project)}") - compile_fun = fn -> - Mix.Task.clear() + Progress.with_progress("Building #{Project.display_name(project)}", fn token -> + Build.set_progress_token(token) - with_progress building_label(project), fn -> - result = compile_in_isolation() - Mix.Task.run(:loadpaths) - result + try do + {:done, do_compile(project, initial?, token)} + after + Build.clear_progress_token() end - end - - case compile_fun.() do - {:error, diagnostics} -> - diagnostics = - diagnostics - |> List.wrap() - |> Build.Error.refine_diagnostics() - - {:error, diagnostics} - - {status, diagnostics} when status in [:ok, :noop] -> - Logger.info( - "Compile completed with status #{status} " <> - "Produced #{length(diagnostics)} diagnostics " <> - inspect(diagnostics) - ) - - Build.Error.refine_diagnostics(diagnostics) - end + end) end) end + defp do_compile(project, initial?, token) do + Mix.Task.clear() + + if initial?, do: prepare_for_project_build(token) + + compile_fun = fn -> + Mix.Task.clear() + Progress.report(token, message: "Compiling #{Project.display_name(project)}") + result = compile_in_isolation() + Mix.Task.run(:loadpaths) + result + end + + case compile_fun.() do + {:error, diagnostics} -> + diagnostics = + diagnostics + |> List.wrap() + |> Build.Error.refine_diagnostics() + + {:error, diagnostics} + + {status, diagnostics} when status in [:ok, :noop] -> + Logger.info( + "Compile completed with status #{status} " <> + "Produced #{length(diagnostics)} diagnostics " <> + inspect(diagnostics) + ) + + Build.Error.refine_diagnostics(diagnostics) + end + end + defp compile_in_isolation do compile_fun = fn -> Mix.Task.run(:compile, mix_compile_opts()) end @@ -66,40 +75,30 @@ defmodule Engine.Build.Project do end end - defp prepare_for_project_build(false = _initial?) do - :ok - end - - defp prepare_for_project_build(true = _initial?) do + defp prepare_for_project_build(token) do if connected_to_internet?() do - with_progress "mix local.hex", fn -> - Mix.Task.run("local.hex", ~w(--force)) - end + Progress.report(token, message: "mix local.hex") + Mix.Task.run("local.hex", ~w(--force)) - with_progress "mix local.rebar", fn -> - Mix.Task.run("local.rebar", ~w(--force)) - end + Progress.report(token, message: "mix local.rebar") + Mix.Task.run("local.rebar", ~w(--force)) - with_progress "mix deps.get", fn -> - Mix.Task.run("deps.get") - end + Progress.report(token, message: "mix deps.get") + Mix.Task.run("deps.get") else Logger.warning("Could not connect to hex.pm, dependencies will not be fetched") end - with_progress "mix loadconfig", fn -> - Mix.Task.run(:loadconfig) - end + Progress.report(token, message: "mix loadconfig") + Mix.Task.run(:loadconfig) - unless Elixir.Features.compile_keeps_current_directory?() do - with_progress "mix deps.compile", fn -> - Mix.Task.run("deps.safe_compile", ~w(--skip-umbrella-children)) - end + if not Elixir.Features.compile_keeps_current_directory?() do + Progress.report(token, message: "mix deps.compile") + Mix.Task.run("deps.safe_compile", ~w(--skip-umbrella-children)) end - with_progress "loading plugins", fn -> - Plugin.Discovery.run() - end + Progress.report(token, message: "Loading plugins") + Plugin.Discovery.run() end defp connected_to_internet? do @@ -113,10 +112,6 @@ defmodule Engine.Build.Project do end end - def building_label(%Project{} = project) do - "Building #{Project.display_name(project)}" - end - defp mix_compile_opts do ~w( --return-errors diff --git a/apps/engine/lib/engine/build/state.ex b/apps/engine/lib/engine/build/state.ex index ca077b48..32768134 100644 --- a/apps/engine/lib/engine/build/state.ex +++ b/apps/engine/lib/engine/build/state.ex @@ -11,8 +11,6 @@ defmodule Engine.Build.State do import Messages - use Engine.Progress - defstruct project: nil, build_number: 0, uri_to_document: %{}, @@ -207,10 +205,6 @@ defmodule Engine.Build.State do end end - def building_label(%Project{} = project) do - "Building #{Project.display_name(project)}" - end - defp to_ms(microseconds) do microseconds / 1000 end diff --git a/apps/engine/lib/engine/compilation/tracer.ex b/apps/engine/lib/engine/compilation/tracer.ex index 6d687984..0b3e5ae4 100644 --- a/apps/engine/lib/engine/compilation/tracer.ex +++ b/apps/engine/lib/engine/compilation/tracer.ex @@ -1,6 +1,7 @@ defmodule Engine.Compilation.Tracer do alias Engine.Build alias Engine.Module.Loader + alias Engine.Progress import Forge.EngineApi.Messages @@ -56,10 +57,9 @@ defmodule Engine.Compilation.Tracer do end defp maybe_report_progress(file) do - if Path.extname(file) == ".ex" do - file - |> progress_message() - |> Engine.broadcast() + with ".ex" <- Path.extname(file), + token when not is_nil(token) <- Build.get_progress_token() do + Progress.report(token, message: progress_message(file)) end end @@ -72,9 +72,6 @@ defmodule Engine.Compilation.Tracer do base_dir = List.first(relative_path_elements) file_name = List.last(relative_path_elements) - message = "compiling: " <> Path.join([base_dir, "...", file_name]) - - label = Build.State.building_label(Engine.get_project()) - project_progress(label: label, message: message) + "compiling: " <> Path.join([base_dir, "...", file_name]) end end diff --git a/apps/engine/lib/engine/dispatch.ex b/apps/engine/lib/engine/dispatch.ex index 752136d3..0012ce5e 100644 --- a/apps/engine/lib/engine/dispatch.ex +++ b/apps/engine/lib/engine/dispatch.ex @@ -9,8 +9,6 @@ defmodule Engine.Dispatch do alias Engine.Dispatch.Handlers alias Engine.Dispatch.PubSub - alias Forge.Project - import Forge.EngineApi.Messages @handlers [PubSub, Handlers.Indexing] @@ -44,17 +42,22 @@ defmodule Engine.Dispatch do :gen_event.notify(__MODULE__, message) end + # bypass via rpc, primarily for progress reporting. + + def erpc_call(module, function, args) do + :erpc.call(Engine.get_manager_node(), module, function, args, 1_000) + end + + def erpc_cast(module, function, args) do + :erpc.cast(Engine.get_manager_node(), module, function, args) + end + # GenServer callbacks - def start_link(opts) do + def start_link(_opts) do case :gen_event.start_link(name()) do {:ok, pid} = success -> Enum.each(@handlers, &:gen_event.add_handler(pid, &1, [])) - - if opts[:progress] do - register_progress_listener() - end - success error -> @@ -69,17 +72,5 @@ defmodule Engine.Dispatch do } end - defp name do - {:local, __MODULE__} - end - - defp register_progress_listener do - register_listener(progress_pid(), [project_progress(), percent_progress()]) - end - - defp progress_pid do - project = Engine.get_project() - manager_node_name = Project.manager_node_name(project) - :rpc.call(manager_node_name, Expert.Project.Progress, :whereis, [project]) - end + defp name, do: {:local, __MODULE__} end diff --git a/apps/engine/lib/engine/progress.ex b/apps/engine/lib/engine/progress.ex index 2c724b62..98a5640f 100644 --- a/apps/engine/lib/engine/progress.ex +++ b/apps/engine/lib/engine/progress.ex @@ -1,66 +1,32 @@ defmodule Engine.Progress do - import Forge.EngineApi.Messages + @moduledoc """ + LSP progress reporting for engine operations. + """ - @type label :: String.t() - @type message :: String.t() + use Forge.Progress - @type delta :: pos_integer() - @type on_complete_callback :: (-> any()) - @type report_progress_callback :: (delta(), message() -> any()) + alias Engine.Dispatch - defmacro __using__(_) do - quote do - import unquote(__MODULE__), only: [with_progress: 2] - end + @impl true + def begin(title, opts \\ []) when is_list(opts) do + Dispatch.erpc_call(Expert.Progress, :begin, [title, opts]) end - @spec with_progress(label(), (-> any())) :: any() - def with_progress(label, func) when is_function(func, 0) do - on_complete = begin_progress(label) + @impl true + def report(@noop_token, _opts), do: :ok - try do - func.() - after - on_complete.() - end + def report(token, [_ | _] = opts) when is_token(token) do + Dispatch.erpc_cast(Expert.Progress, :report, [token, opts]) + :ok end - @spec with_percent_progress(label(), pos_integer(), (report_progress_callback() -> any())) :: - any() - def with_percent_progress(label, max, func) when is_function(func, 1) do - {report_progress, on_complete} = begin_percent(label, max) + @impl true + def complete(token, opts \\ []) - try do - func.(report_progress) - after - on_complete.() - end - end - - @spec begin_progress(label :: label()) :: on_complete_callback() - def begin_progress(label) do - Engine.broadcast(project_progress(label: label, stage: :begin)) - - fn -> - Engine.broadcast(project_progress(label: label, stage: :complete)) - end - end - - @spec begin_percent(label(), pos_integer()) :: - {report_progress_callback(), on_complete_callback()} - def begin_percent(label, max) do - Engine.broadcast(percent_progress(label: label, max: max, stage: :begin)) - - report_progress = fn delta, message -> - Engine.broadcast( - percent_progress(label: label, message: message, delta: delta, stage: :report) - ) - end - - complete = fn -> - Engine.broadcast(percent_progress(label: label, stage: :complete)) - end + def complete(@noop_token, _opts), do: :ok - {report_progress, complete} + def complete(token, opts) when is_token(token) and is_list(opts) do + Dispatch.erpc_cast(Expert.Progress, :complete, [token, opts]) + :ok end end diff --git a/apps/engine/lib/engine/search/indexer.ex b/apps/engine/lib/engine/search/indexer.ex index 07501ba3..1a2155a4 100644 --- a/apps/engine/lib/engine/search/indexer.ex +++ b/apps/engine/lib/engine/search/indexer.ex @@ -84,6 +84,7 @@ defmodule Engine.Search.Indexer do # 128 K blocks indexed expert in 5.3 seconds @bytes_per_block 1024 * 128 + defp async_chunks(file_paths, processor, timeout \\ :infinity) do # this function tries to even out the amount of data processed by # async stream by making each chunk emitted by the initial stream to @@ -96,66 +97,60 @@ defmodule Engine.Search.Indexer do |> path_to_sizes() |> Enum.shuffle() - path_to_size_map = Map.new(paths_to_sizes) - total_bytes = paths_to_sizes |> Enum.map(&elem(&1, 1)) |> Enum.sum() if total_bytes > 0 do - {on_update_progress, on_complete} = - Progress.begin_percent("Indexing source code", total_bytes) + process_chunks(paths_to_sizes, total_bytes, processor, timeout) + else + [] + end + end - initial_state = {0, []} + defp process_chunks(paths_to_sizes, total_bytes, processor, timeout) do + path_to_size_map = Map.new(paths_to_sizes) - chunk_fn = fn {path, file_size}, {block_size, paths} -> - new_block_size = file_size + block_size - new_paths = [path | paths] + Progress.with_tracked_progress("Indexing source code", total_bytes, fn report -> + result = do_process_chunks(paths_to_sizes, path_to_size_map, processor, timeout, report) + {:done, result} + end) + end - if new_block_size >= @bytes_per_block do - {:cont, new_paths, initial_state} - else - {:cont, {new_block_size, new_paths}} - end - end + defp do_process_chunks(paths_to_sizes, path_to_size_map, processor, timeout, report) do + initial_state = {0, []} - after_fn = fn - {_, []} -> - {:cont, []} + chunk_fn = fn {path, file_size}, {block_size, paths} -> + new_block_size = file_size + block_size + new_paths = [path | paths] - {_, paths} -> - {:cont, paths, []} + if new_block_size >= @bytes_per_block do + {:cont, new_paths, initial_state} + else + {:cont, {new_block_size, new_paths}} end + end - paths_to_sizes - |> Stream.chunk_while(initial_state, chunk_fn, after_fn) - |> Task.async_stream( - fn chunk -> - block_bytes = chunk |> Enum.map(&Map.get(path_to_size_map, &1)) |> Enum.sum() - result = Enum.map(chunk, processor) - on_update_progress.(block_bytes, "Indexing") - result - end, - timeout: timeout - ) - |> Stream.flat_map(fn - {:ok, entry_chunks} -> entry_chunks - _ -> [] - end) - # The next bit is the only way i could figure out how to - # call complete once the stream was realized - |> Stream.transform( - fn -> nil end, - fn chunk_items, acc -> - # By the chunk items list directly, each transformation - # will flatten the resulting steam - {chunk_items, acc} - end, - fn _acc -> - on_complete.() - end - ) - else - [] + after_fn = fn + {_, []} -> {:cont, []} + {_, paths} -> {:cont, paths, []} end + + paths_to_sizes + |> Stream.chunk_while(initial_state, chunk_fn, after_fn) + |> Task.async_stream( + fn chunk -> + block_bytes = chunk |> Enum.map(&Map.get(path_to_size_map, &1)) |> Enum.sum() + + report.(message: "Indexing", add: block_bytes) + + Enum.flat_map(chunk, processor) + end, + timeout: timeout + ) + |> Stream.flat_map(fn + {:ok, entries} -> entries + _ -> [] + end) + |> Enum.to_list() end defp path_to_sizes(paths) do diff --git a/apps/engine/test/engine/api/proxy_test.exs b/apps/engine/test/engine/api/proxy_test.exs index fcf92d04..a4ab88ef 100644 --- a/apps/engine/test/engine/api/proxy_test.exs +++ b/apps/engine/test/engine/api/proxy_test.exs @@ -32,13 +32,6 @@ defmodule Engine.Api.ProxyTest do assert_called(Dispatch.broadcast(:hello)) end - test "proxies broadcasts of progress messages" do - patch(Dispatch, :broadcast, :ok) - assert :ok = Proxy.broadcast(percent_progress()) - - assert_called(Dispatch.broadcast(percent_progress())) - end - test "schedule compile is proxied", %{project: project} do patch(Build, :schedule_compile, :ok) assert :ok = Proxy.schedule_compile(true) @@ -150,13 +143,6 @@ defmodule Engine.Api.ProxyTest do assert {:error, {:already_buffering, _}} = Proxy.start_buffering() end - test "proxies broadcasts of progress messages" do - patch(Dispatch, :broadcast, :ok) - assert :ok = Proxy.broadcast(percent_progress()) - - assert_called(Dispatch.broadcast(percent_progress())) - end - test "buffers broadcasts" do assert :ok = Proxy.broadcast(file_compile_requested()) refute_any_call(Dispatch.broadcast()) diff --git a/apps/engine/test/engine/dispatch/handler_test.exs b/apps/engine/test/engine/dispatch/handler_test.exs index 59c65995..ed4a5ffb 100644 --- a/apps/engine/test/engine/dispatch/handler_test.exs +++ b/apps/engine/test/engine/dispatch/handler_test.exs @@ -69,7 +69,7 @@ defmodule Engine.Dispatch.HandlerTest do Dispatch.broadcast(file_changed()) refute_receive {SelectiveForwarder, _} - Dispatch.broadcast(project_progress()) + Dispatch.broadcast(project_compiled()) refute_receive {SelectiveForwarder, _} end end diff --git a/apps/engine/test/engine/dispatch/handlers/indexer_test.exs b/apps/engine/test/engine/dispatch/handlers/indexer_test.exs index ca4776a3..74cfa8e7 100644 --- a/apps/engine/test/engine/dispatch/handlers/indexer_test.exs +++ b/apps/engine/test/engine/dispatch/handlers/indexer_test.exs @@ -19,6 +19,15 @@ defmodule Engine.Dispatch.Handlers.IndexingTest do create_index = &Search.Indexer.create_index/1 update_index = &Search.Indexer.update_index/2 + # Mock the broadcast so progress reporting doesn't fail + patch(Engine.Api.Proxy, :broadcast, fn _ -> :ok end) + # Mock erpc calls for progress reporting + patch(Engine.Dispatch, :erpc_call, fn Expert.Progress, :begin, [_title, _opts] -> + {:ok, System.unique_integer([:positive])} + end) + + patch(Engine.Dispatch, :erpc_cast, fn Expert.Progress, _function, _args -> true end) + start_supervised!(Engine.Dispatch) start_supervised!(Commands.Reindex) start_supervised!(Search.Store.Backends.Ets) diff --git a/apps/engine/test/engine/progress_test.exs b/apps/engine/test/engine/progress_test.exs index 19b12a04..455065b7 100644 --- a/apps/engine/test/engine/progress_test.exs +++ b/apps/engine/test/engine/progress_test.exs @@ -1,32 +1,224 @@ defmodule Engine.ProgressTest do - alias Engine.Progress - - import Forge.EngineApi.Messages - use ExUnit.Case use Patch - use Progress + + alias Engine.Dispatch + alias Engine.Progress setup do test_pid = self() - patch(Engine.Api.Proxy, :broadcast, &send(test_pid, &1)) + + # Mock erpc_call for begin - returns {:ok, token} + patch(Dispatch, :erpc_call, fn Expert.Progress, :begin, [title, opts] -> + token = System.unique_integer([:positive]) + send(test_pid, {:begin, token, title, opts}) + {:ok, token} + end) + + # Mock erpc_cast for report and complete + patch(Dispatch, :erpc_cast, fn Expert.Progress, function, args -> + send(test_pid, {function, args}) + true + end) + :ok end test "it should send begin/complete event and return the result" do - result = with_progress "foo", fn -> :ok end + result = Progress.with_progress("foo", fn _token -> {:done, :ok} end) assert result == :ok - assert_received project_progress(label: "foo", stage: :begin) - assert_received project_progress(label: "foo", stage: :complete) + assert_received {:begin, token, "foo", []} when is_integer(token) + assert_received {:complete, [^token, []]} end - test "it should send begin/complete event even there is an exception" do + test "it should send begin/complete event with final message" do + result = Progress.with_progress("bar", fn _token -> {:done, :success, "Completed!"} end) + + assert result == :success + assert_received {:begin, token, "bar", []} when is_integer(token) + assert_received {:complete, [^token, [message: "Completed!"]]} + end + + test "it should send report events when Progress.report is called" do + result = + Progress.with_progress("indexing", fn token -> + Progress.report(token, message: "Processing file 1...") + Progress.report(token, message: "Processing file 2...", percentage: 50) + {:done, :indexed} + end) + + assert result == :indexed + assert_received {:begin, token, "indexing", []} when is_integer(token) + assert_received {:report, [^token, [message: "Processing file 1..."]]} + assert_received {:report, [^token, [message: "Processing file 2...", percentage: 50]]} + assert_received {:complete, [^token, []]} + end + + test "it should send begin/complete event even when there is an exception" do assert_raise(Mix.Error, fn -> - with_progress "compile", fn -> raise Mix.Error, "can't compile" end + Progress.with_progress("compile", fn _token -> raise Mix.Error, "can't compile" end) end) - assert_received project_progress(label: "compile", stage: :begin) - assert_received project_progress(label: "compile", stage: :complete) + assert_received {:begin, token, "compile", []} when is_integer(token) + assert_received {:complete, [^token, [message: "Error: can't compile"]]} + end + + test "it should handle cancel result" do + result = Progress.with_progress("cancellable", fn _token -> {:cancel, :cancelled} end) + + assert result == :cancelled + assert_received {:begin, token, "cancellable", []} when is_integer(token) + assert_received {:complete, [^token, [message: "Cancelled"]]} + end + + test "it should pass through initial options" do + _result = + Progress.with_progress( + "with_opts", + fn _token -> {:done, :ok} end, + message: "Starting...", + percentage: 0 + ) + + assert_received {:begin, _token, "with_opts", opts} + assert opts[:message] == "Starting..." + assert opts[:percentage] == 0 + end + + describe "with_tracked_progress/3" do + test "tracks progress via GenServer and reports percentage" do + result = + Progress.with_tracked_progress("Indexing", 100, fn report -> + report.(message: "Processing", add: 25) + report.(message: "Processing", add: 25) + report.(message: "Processing", add: 50) + {:done, :indexed} + end) + + assert result == :indexed + assert_received {:begin, token, "Indexing", [percentage: 0]} when is_integer(token) + assert_received {:report, [^token, [message: "Processing", percentage: 25]]} + assert_received {:report, [^token, [message: "Processing", percentage: 50]]} + assert_received {:report, [^token, [message: "Processing", percentage: 100]]} + assert_received {:complete, [^token, []]} + end + + test "handles concurrent updates from multiple tasks" do + result = + Progress.with_tracked_progress("Concurrent", 100, fn report -> + 1..10 + |> Task.async_stream(fn i -> + report.(message: "Task #{i}", add: 10) + i + end) + |> Enum.map(fn {:ok, i} -> i end) + |> then(&{:done, &1}) + end) + + assert result == Enum.to_list(1..10) + assert_received {:begin, token, "Concurrent", [percentage: 0]} when is_integer(token) + # Should receive 10 report messages (order may vary due to concurrency) + for _ <- 1..10 do + assert_received {:report, [^token, [message: _, percentage: _]]} + end + + assert_received {:complete, [^token, []]} + end + + test "completes with final message" do + result = + Progress.with_tracked_progress("WithMessage", 10, fn report -> + report.(message: "Working", add: 10) + {:done, :success, "All done!"} + end) + + assert result == :success + assert_received {:begin, token, "WithMessage", [percentage: 0]} when is_integer(token) + assert_received {:complete, [^token, [message: "All done!"]]} + end + + test "handles cancel result" do + result = + Progress.with_tracked_progress("Cancellable", 100, fn _report -> + {:cancel, :stopped} + end) + + assert result == :stopped + assert_received {:begin, token, "Cancellable", [percentage: 0]} when is_integer(token) + assert_received {:complete, [^token, [message: "Cancelled"]]} + end + + test "cleans up tracker on exception" do + assert_raise RuntimeError, "oops", fn -> + Progress.with_tracked_progress("Failing", 100, fn _report -> + raise "oops" + end) + end + + assert_received {:begin, token, "Failing", [percentage: 0]} when is_integer(token) + assert_received {:complete, [^token, [message: "Error: oops"]]} + end + + test "caps percentage at 100 when add exceeds total" do + result = + Progress.with_tracked_progress("Overflow", 50, fn report -> + report.(message: "Big chunk", add: 100) + {:done, :ok} + end) + + assert result == :ok + assert_received {:begin, token, "Overflow", [percentage: 0]} when is_integer(token) + assert_received {:report, [^token, [message: "Big chunk", percentage: 100]]} + end + end + + describe "with_tracked_progress/4 with custom report function" do + test "uses custom report callback" do + test_pid = self() + + custom_report = fn message, current, total, token -> + send(test_pid, {:custom_report, message, current, total, token}) + end + + result = + Progress.with_tracked_progress( + "Custom", + 10, + fn report -> + report.(message: "Step 1", add: 3) + report.(message: "Step 2", add: 7) + {:done, :customized} + end, + custom_report + ) + + assert result == :customized + assert_received {:begin, token, "Custom", [percentage: 0]} when is_integer(token) + assert_received {:custom_report, "Step 1", 3, 10, ^token} + assert_received {:custom_report, "Step 2", 10, 10, ^token} + assert_received {:complete, [^token, []]} + end + + test "custom report receives nil message when not provided" do + test_pid = self() + + custom_report = fn message, current, total, token -> + send(test_pid, {:custom_report, message, current, total, token}) + end + + Progress.with_tracked_progress( + "NoMessage", + 10, + fn report -> + report.(add: 5) + {:done, :ok} + end, + custom_report + ) + + assert_received {:begin, token, "NoMessage", [percentage: 0]} when is_integer(token) + assert_received {:custom_report, nil, 5, 10, ^token} + end end end diff --git a/apps/engine/test/engine/search/indexer_test.exs b/apps/engine/test/engine/search/indexer_test.exs index 25965518..94ab135a 100644 --- a/apps/engine/test/engine/search/indexer_test.exs +++ b/apps/engine/test/engine/search/indexer_test.exs @@ -26,6 +26,14 @@ defmodule Engine.Search.IndexerTest do setup do project = project() start_supervised(Dispatch) + # Mock the broadcast so progress reporting doesn't fail + patch(Engine.Api.Proxy, :broadcast, fn _ -> :ok end) + # Mock erpc calls for progress reporting + patch(Dispatch, :erpc_call, fn Expert.Progress, :begin, [_title, _opts] -> + {:ok, System.unique_integer([:positive])} + end) + + patch(Dispatch, :erpc_cast, fn Expert.Progress, _function, _args -> true end) {:ok, project: project} end diff --git a/apps/expert/.formatter.exs b/apps/expert/.formatter.exs index f56e60d1..d7949373 100644 --- a/apps/expert/.formatter.exs +++ b/apps/expert/.formatter.exs @@ -6,10 +6,8 @@ imported_deps = [:forge] end -locals_without_parens = [with_progress: 3] - [ - locals_without_parens: locals_without_parens, + locals_without_parens: [], inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], import_deps: imported_deps ] diff --git a/apps/expert/lib/expert.ex b/apps/expert/lib/expert.ex index 1d5905f4..b4055ab3 100644 --- a/apps/expert/lib/expert.ex +++ b/apps/expert/lib/expert.ex @@ -57,6 +57,8 @@ defmodule Expert do with {:ok, response, state} <- State.initialize(state, request), {:ok, response} <- Expert.Protocol.Convert.to_lsp(response) do Task.Supervisor.start_child(:expert_task_queue, fn -> + # dirty sleep to allow initialize response to return before progress reports + Process.sleep(50) config = state.configuration log_info(lsp, "Starting project") diff --git a/apps/expert/lib/expert/engine_node.ex b/apps/expert/lib/expert/engine_node.ex index b54e13f7..73d93faa 100644 --- a/apps/expert/lib/expert/engine_node.ex +++ b/apps/expert/lib/expert/engine_node.ex @@ -1,8 +1,8 @@ defmodule Expert.EngineNode do + alias Expert.Progress alias Forge.Project - require Logger - use Expert.Project.Progress.Support + require Logger defmodule State do require Logger @@ -182,17 +182,19 @@ defmodule Expert.EngineNode do alias Forge.Document use GenServer - def start(project) do + def start(project, token \\ Progress.noop_token()) do start_net_kernel(project) node_name = Project.node_name(project) - bootstrap_args = [project, Document.Store.entropy(), all_app_configs()] + bootstrap_args = [project, Document.Store.entropy(), all_app_configs(), Node.self()] with {:ok, node_pid} <- EngineSupervisor.start_project_node(project), {:ok, glob_paths} <- glob_paths(project), + :ok <- Progress.report(token, message: "Starting Erlang node..."), :ok <- start_node(project, glob_paths), + :ok <- Progress.report(token, message: "Bootstrapping engine..."), :ok <- :rpc.call(node_name, Engine.Bootstrap, :init, bootstrap_args), - :ok <- ensure_apps_started(node_name) do + :ok <- ensure_apps_started(node_name, token) do {:ok, node_name, node_pid} end end @@ -202,8 +204,8 @@ defmodule Expert.EngineNode do Node.start(manager, :longnames) end - defp ensure_apps_started(node) do - :rpc.call(node, Engine, :ensure_apps_started, []) + defp ensure_apps_started(node, token) do + :rpc.call(node, Engine, :ensure_apps_started, [token]) end if Mix.env() == :test do @@ -240,9 +242,7 @@ defmodule Expert.EngineNode do defp launch_engine_builder(project, elixir, env) do lsp = Expert.get_lsp() - project_name = Project.name(project) - Logger.info("Found elixir for #{project_name} at #{elixir}") - GenLSP.info(lsp, "Found elixir for #{project_name} at #{elixir}") + Expert.log_info(lsp, "Found elixir executable at #{elixir}") expert_priv = :code.priv_dir(:expert) packaged_engine_source = Path.join([expert_priv, "engine_source", "apps", "engine"]) @@ -281,18 +281,23 @@ defmodule Expert.EngineNode do {launcher, opts} end - GenLSP.info(lsp, "Finding or building engine for project #{project_name}") + Expert.log_info(lsp, "Finding or building engine") + + project_name = Project.name(project) - with_progress(project, "Building engine for #{project_name}", fn -> - fn -> - Process.flag(:trap_exit, true) + Expert.Progress.with_progress("Building engine for #{project_name}", fn _token -> + result = + fn -> + Process.flag(:trap_exit, true) - {:spawn_executable, launcher} - |> Port.open([:stderr_to_stdout | opts]) - |> wait_for_engine() - end - |> Task.async() - |> Task.await(:infinity) + {:spawn_executable, launcher} + |> Port.open([:stderr_to_stdout | opts]) + |> wait_for_engine() + end + |> Task.async() + |> Task.await(:infinity) + + {:done, result, "Engine node built for #{project_name}."} end) end diff --git a/apps/expert/lib/expert/progress.ex b/apps/expert/lib/expert/progress.ex new file mode 100644 index 00000000..4d3149de --- /dev/null +++ b/apps/expert/lib/expert/progress.ex @@ -0,0 +1,140 @@ +defmodule Expert.Progress do + @moduledoc """ + LSP progress reporting for the Expert language server. + """ + + use Forge.Progress + + alias Expert.Configuration + alias Expert.Protocol.Id + alias GenLSP.{Notifications, Requests, Structures} + + require Logger + + # Behaviour implementations + + @doc """ + Begins server-initiated progress. + + Generates a token, requests the client create the progress indicator, + and sends the begin notification. + + ## Options + + * `:message` - Initial status message (optional) + * `:percentage` - Initial percentage 0-100 (optional) + * `:cancellable` - Whether the client can cancel (default: false) + * `:token` - Custom token to use (caller ensures uniqueness) + + ## Examples + + {:ok, token} = Progress.begin("Building project") + {:ok, token} = Progress.begin("Indexing", message: "Starting...", percentage: 0) + {:ok, token} = Progress.begin("Custom", token: my_unique_token) + """ + @impl Forge.Progress + def begin(title, opts \\ []) do + opts = Keyword.validate!(opts, [:message, :percentage, :cancellable, :token]) + + token = opts[:token] || System.unique_integer([:positive]) + + if Configuration.client_support(:work_done_progress) do + case request_work_done_progress(token) do + :ok -> + notify(token, progress_begin(title, opts)) + {:ok, token} + + {:error, reason} -> + Logger.warning("Client rejected progress token: #{inspect(reason)}") + {:error, :rejected} + end + else + {:ok, @noop_token} + end + end + + @doc """ + Reports progress update. + + ## Options + + * `:message` - Status message (optional) + * `:percentage` - Percentage 0-100 (optional) + + ## Examples + + Progress.report(token, message: "Processing file 1...") + Progress.report(token, message: "Halfway there", percentage: 50) + """ + @impl Forge.Progress + def report(@noop_token, _opts), do: :ok + + def report(token, [_ | _] = opts) when is_token(token) do + notify(token, progress_report(opts)) + :ok + end + + @doc """ + Ends a progress sequence. + + ## Options + + * `:message` - Final completion message (optional) + + ## Examples + + Progress.complete(token) + Progress.complete(token, message: "Build complete") + """ + @impl Forge.Progress + def complete(token, opts \\ []) + + def complete(@noop_token, _opts), do: :ok + + def complete(token, opts) when is_token(token) do + notify(token, progress_end(opts)) + :ok + end + + # Private helpers + + defp request_work_done_progress(token) do + Expert.get_lsp() + |> GenLSP.request(%Requests.WindowWorkDoneProgressCreate{ + id: Id.next(), + params: %Structures.WorkDoneProgressCreateParams{token: token} + }) + |> case do + nil -> :ok + error -> {:error, error} + end + end + + defp notify(token, value) do + GenLSP.notify(Expert.get_lsp(), %Notifications.DollarProgress{ + params: %Structures.ProgressParams{token: token, value: value} + }) + end + + defp progress_begin(title, opts) do + %Structures.WorkDoneProgressBegin{ + kind: "begin", + title: title, + message: opts[:message], + percentage: opts[:percentage], + cancellable: opts[:cancellable] + } + end + + defp progress_report(opts) do + %Structures.WorkDoneProgressReport{ + kind: "report", + message: opts[:message], + percentage: opts[:percentage] + } + end + + defp progress_end(opts) do + %Structures.WorkDoneProgressEnd{kind: "end", message: opts[:message]} + end +end diff --git a/apps/expert/lib/expert/project/node.ex b/apps/expert/lib/expert/project/node.ex index b91e40c0..d4471c09 100644 --- a/apps/expert/lib/expert/project/node.ex +++ b/apps/expert/lib/expert/project/node.ex @@ -15,12 +15,11 @@ defmodule Expert.Project.Node do alias Expert.EngineApi alias Expert.EngineNode - alias Expert.Project.Progress + alias Expert.Progress require Logger use GenServer - use Progress.Support def start_link(%Project{} = project) do GenServer.start_link(__MODULE__, project, name: name(project)) @@ -51,12 +50,16 @@ defmodule Expert.Project.Node do @impl GenServer def init(%Project{} = project) do - case with_progress(project, "Project Node", fn -> start_node(project) end) do - {:ok, state} -> - {:ok, state, {:continue, :trigger_build}} + result = + Progress.with_progress("Starting project node", fn token -> + result = start_node(project, token) - error -> - {:stop, error} + {:done, result, "Project node started"} + end) + + case result do + {:ok, state} -> {:ok, state, {:continue, :trigger_build}} + error -> {:stop, error} end end @@ -92,8 +95,8 @@ defmodule Expert.Project.Node do # private api - defp start_node(%Project{} = project) do - with {:ok, node, node_pid} <- EngineNode.start(project) do + defp start_node(%Project{} = project, token \\ Progress.noop_token()) do + with {:ok, node, node_pid} <- EngineNode.start(project, token) do Node.monitor(node, true) {:ok, State.new(project, node, node_pid)} end diff --git a/apps/expert/lib/expert/project/progress.ex b/apps/expert/lib/expert/project/progress.ex deleted file mode 100644 index 73561c28..00000000 --- a/apps/expert/lib/expert/project/progress.ex +++ /dev/null @@ -1,46 +0,0 @@ -defmodule Expert.Project.Progress do - alias Expert.Project.Progress.State - alias Forge.Project - - import Forge.EngineApi.Messages - - use GenServer - - def start_link(%Project{} = project) do - GenServer.start_link(__MODULE__, [project], name: name(project)) - end - - def child_spec(%Project{} = project) do - %{ - id: {__MODULE__, Project.name(project)}, - start: {__MODULE__, :start_link, [project]} - } - end - - # GenServer callbacks - - @impl GenServer - def init([project]) do - {:ok, State.new(project)} - end - - @impl true - def handle_info(project_progress(stage: stage) = message, %State{} = state) do - new_state = apply(State, stage, [state, message]) - {:noreply, new_state} - end - - def handle_info(percent_progress(stage: stage) = message, %State{} = state) do - new_state = apply(State, stage, [state, message]) - - {:noreply, new_state} - end - - def name(%Project{} = project) do - :"#{Project.name(project)}::progress" - end - - def whereis(%Project{} = project) do - project |> name() |> Process.whereis() - end -end diff --git a/apps/expert/lib/expert/project/progress/percentage.ex b/apps/expert/lib/expert/project/progress/percentage.ex deleted file mode 100644 index 47130e4d..00000000 --- a/apps/expert/lib/expert/project/progress/percentage.ex +++ /dev/null @@ -1,72 +0,0 @@ -defmodule Expert.Project.Progress.Percentage do - @moduledoc """ - The backing data structure for percentage based progress reports - """ - alias Forge.Math - alias GenLSP.Notifications - alias GenLSP.Structures - - @enforce_keys [:token, :kind, :max] - defstruct [:token, :kind, :title, :message, :max, current: 0] - - def begin(title, max) do - token = System.unique_integer([:positive]) - %__MODULE__{token: token, kind: :begin, title: title, max: max} - end - - def report(percentage, delta, message \\ "") - - def report(%__MODULE__{} = percentage, delta, message) when is_integer(delta) and delta >= 0 do - new_current = percentage.current + delta - - %__MODULE__{percentage | kind: :report, message: message, current: new_current} - end - - def report(%__MODULE__{} = percentage, delta, _message) when is_integer(delta) do - percentage - end - - def report(_, _, _) do - nil - end - - def complete(%__MODULE__{} = percentage, message) do - %__MODULE__{percentage | kind: :end, current: percentage.max, message: message} - end - - def to_protocol(%__MODULE__{kind: :begin} = value) do - %Notifications.DollarProgress{ - params: %Structures.ProgressParams{ - token: value.token, - value: %Structures.WorkDoneProgressBegin{kind: "begin", title: value.title, percentage: 0} - } - } - end - - def to_protocol(%__MODULE__{kind: :report} = value) do - percent_complete = - (value.current / value.max * 100) - |> round() - |> Math.clamp(0, 100) - - %Notifications.DollarProgress{ - params: %Structures.ProgressParams{ - token: value.token, - value: %Structures.WorkDoneProgressReport{ - kind: "report", - message: value.message, - percentage: percent_complete - } - } - } - end - - def to_protocol(%__MODULE__{kind: :end} = value) do - %Notifications.DollarProgress{ - params: %Structures.ProgressParams{ - token: value.token, - value: %Structures.WorkDoneProgressEnd{kind: "end", message: value.message} - } - } - end -end diff --git a/apps/expert/lib/expert/project/progress/state.ex b/apps/expert/lib/expert/project/progress/state.ex deleted file mode 100644 index 03bd12be..00000000 --- a/apps/expert/lib/expert/project/progress/state.ex +++ /dev/null @@ -1,111 +0,0 @@ -defmodule Expert.Project.Progress.State do - alias Expert.Configuration - alias Expert.Project.Progress.Percentage - alias Expert.Project.Progress.Value - alias Expert.Protocol.Id - alias Forge.Project - alias GenLSP.Requests - alias GenLSP.Structures - - import Forge.EngineApi.Messages - - defstruct project: nil, progress_by_label: %{} - - def new(%Project{} = project) do - %__MODULE__{project: project} - end - - def begin(%__MODULE__{} = state, project_progress(label: label)) do - progress = Value.begin(label) - progress_by_label = Map.put(state.progress_by_label, label, progress) - - write_work_done(Expert.get_lsp(), progress.token) - write(Expert.get_lsp(), progress) - - %__MODULE__{state | progress_by_label: progress_by_label} - end - - def begin(%__MODULE__{} = state, percent_progress(label: label, max: max)) do - progress = Percentage.begin(label, max) - progress_by_label = Map.put(state.progress_by_label, label, progress) - write_work_done(Expert.get_lsp(), progress.token) - write(Expert.get_lsp(), progress) - - %__MODULE__{state | progress_by_label: progress_by_label} - end - - def report(%__MODULE__{} = state, project_progress(label: label, message: message)) do - {progress, progress_by_label} = - Map.get_and_update(state.progress_by_label, label, fn old_value -> - new_value = Value.report(old_value, message) - {new_value, new_value} - end) - - write(Expert.get_lsp(), progress) - %__MODULE__{state | progress_by_label: progress_by_label} - end - - def report( - %__MODULE__{} = state, - percent_progress(label: label, message: message, delta: delta) - ) do - {progress, progress_by_label} = - Map.get_and_update(state.progress_by_label, label, fn old_percentage -> - new_percentage = Percentage.report(old_percentage, delta, message) - {new_percentage, new_percentage} - end) - - write(Expert.get_lsp(), progress) - %__MODULE__{state | progress_by_label: progress_by_label} - end - - def complete(%__MODULE__{} = state, project_progress(label: label, message: message)) do - {progress, progress_by_label} = - Map.get_and_update(state.progress_by_label, label, fn _ -> :pop end) - - case progress do - %Value{} = progress -> - write(Expert.get_lsp(), Value.complete(progress, message)) - - _ -> - :ok - end - - %__MODULE__{state | progress_by_label: progress_by_label} - end - - def complete(%__MODULE__{} = state, percent_progress(label: label, message: message)) do - {progress, progress_by_label} = - Map.get_and_update(state.progress_by_label, label, fn _ -> :pop end) - - case progress do - %Percentage{} = progress -> - write(Expert.get_lsp(), Percentage.complete(progress, message)) - - nil -> - :ok - end - - %__MODULE__{state | progress_by_label: progress_by_label} - end - - defp write_work_done(lsp, token) do - if Configuration.client_support(:work_done_progress) == true do - GenLSP.request(lsp, %Requests.WindowWorkDoneProgressCreate{ - id: Id.next(), - params: %Structures.WorkDoneProgressCreateParams{token: token} - }) - end - end - - defp write(lsp, %progress_module{token: token} = progress) when not is_nil(token) do - if Configuration.client_support(:work_done_progress) == true do - GenLSP.notify( - lsp, - progress_module.to_protocol(progress) - ) - end - end - - defp write(_, _), do: :ok -end diff --git a/apps/expert/lib/expert/project/progress/support.ex b/apps/expert/lib/expert/project/progress/support.ex deleted file mode 100644 index 148f5f27..00000000 --- a/apps/expert/lib/expert/project/progress/support.ex +++ /dev/null @@ -1,42 +0,0 @@ -defmodule Expert.Project.Progress.Support do - alias Expert.Project.Progress - alias Forge.Project - - import Forge.EngineApi.Messages - - defmacro __using__(_) do - quote do - import unquote(__MODULE__), only: [with_progress: 3] - end - end - - def with_progress(project, label, func) when is_function(func, 0) do - dest = Progress.name(project) - - try do - send(dest, project_progress(label: label, stage: :begin)) - func.() - after - send(dest, project_progress(label: label, stage: :complete)) - end - end - - def with_percentage_progress(%Project{} = project, label, max, func) - when is_function(func, 1) do - dest = Progress.name(project) - - report_progress = fn delta, message -> - message = - percent_progress(label: label, max: max, message: message, delta: delta, stage: :report) - - send(dest, message) - end - - try do - send(dest, percent_progress(label: label, max: max, stage: :begin)) - func.(report_progress) - after - send(dest, percent_progress(label: label, stage: :complete)) - end - end -end diff --git a/apps/expert/lib/expert/project/progress/value.ex b/apps/expert/lib/expert/project/progress/value.ex deleted file mode 100644 index 59f3ae3c..00000000 --- a/apps/expert/lib/expert/project/progress/value.ex +++ /dev/null @@ -1,51 +0,0 @@ -defmodule Expert.Project.Progress.Value do - alias GenLSP.Notifications - alias GenLSP.Structures - - @enforce_keys [:token, :kind] - defstruct [:token, :kind, :title, :message] - - def begin(title) do - token = System.unique_integer([:positive]) - %__MODULE__{token: token, kind: :begin, title: title} - end - - def report(%__MODULE__{token: token}, message) do - %__MODULE__{token: token, kind: :report, message: message} - end - - def report(_, _) do - nil - end - - def complete(%__MODULE__{token: token}, message) do - %__MODULE__{token: token, kind: :end, message: message} - end - - def to_protocol(%__MODULE__{kind: :begin} = value) do - %Notifications.DollarProgress{ - params: %Structures.ProgressParams{ - token: value.token, - value: %Structures.WorkDoneProgressBegin{kind: "begin", title: value.title} - } - } - end - - def to_protocol(%__MODULE__{kind: :report} = value) do - %Notifications.DollarProgress{ - params: %Structures.ProgressParams{ - token: value.token, - value: %Structures.WorkDoneProgressReport{kind: "report", message: value.message} - } - } - end - - def to_protocol(%__MODULE__{kind: :end} = value) do - %Notifications.DollarProgress{ - params: %Structures.ProgressParams{ - token: value.token, - value: %Structures.WorkDoneProgressEnd{kind: "end", message: value.message} - } - } - end -end diff --git a/apps/expert/lib/expert/project/supervisor.ex b/apps/expert/lib/expert/project/supervisor.ex index d23d6d65..2abfb872 100644 --- a/apps/expert/lib/expert/project/supervisor.ex +++ b/apps/expert/lib/expert/project/supervisor.ex @@ -3,7 +3,6 @@ defmodule Expert.Project.Supervisor do alias Expert.Project.Diagnostics alias Expert.Project.Intelligence alias Expert.Project.Node - alias Expert.Project.Progress alias Expert.Project.SearchListener alias Forge.Project @@ -25,7 +24,6 @@ defmodule Expert.Project.Supervisor do def init(%Project{} = project) do children = [ - {Progress, project}, {EngineSupervisor, project}, {Node, project}, {Diagnostics, project}, diff --git a/apps/expert/test/engine/build_test.exs b/apps/expert/test/engine/build_test.exs index 29dbaa70..dc010698 100644 --- a/apps/expert/test/engine/build_test.exs +++ b/apps/expert/test/engine/build_test.exs @@ -12,6 +12,7 @@ defmodule Engine.BuildTest do import Messages import Forge.Test.Fixtures import Forge.Test.DiagnosticSupport + use ExUnit.Case use Patch @@ -76,9 +77,7 @@ defmodule Engine.BuildTest do {:ok, project} = with_project(:project_metadata) EngineApi.schedule_compile(project, true) - assert_receive project_compiled(status: :success) - assert_receive project_progress(label: "Building " <> project_name) - assert project_name == "project_metadata" + assert_receive project_compiled(status: :success), :timer.seconds(5) end test "receives metadata about the defined modules" do @@ -112,9 +111,6 @@ defmodule Engine.BuildTest do assert {:arity_0, 0} in functions assert {:arity_1, 1} in functions assert {:arity_2, 2} in functions - - assert_receive project_progress(label: "Building " <> project_name) - assert project_name == "umbrella" end end @@ -123,7 +119,7 @@ defmodule Engine.BuildTest do {:ok, project} = with_project(:compilation_errors) EngineApi.schedule_compile(project, true) - assert_receive project_compiled(status: :error) + assert_receive project_compiled(status: :error), :timer.seconds(5) assert_receive project_diagnostics(diagnostics: [%Diagnostic.Result{}]) end end @@ -148,7 +144,7 @@ defmodule Engine.BuildTest do test "stuff when #{inspect(@feature_condition)}", %{project: project} do EngineApi.schedule_compile(project, true) - assert_receive project_compiled(status: :error) + assert_receive project_compiled(status: :error), :timer.seconds(5) assert_receive project_diagnostics(diagnostics: [%Diagnostic.Result{} = diagnostic]) assert diagnostic.uri @@ -164,7 +160,7 @@ defmodule Engine.BuildTest do {:ok, project} = with_project(:compilation_warnings) EngineApi.schedule_compile(project, true) - assert_receive project_compiled(status: :success) + assert_receive project_compiled(status: :success), :timer.seconds(5) assert_receive project_diagnostics(diagnostics: diagnostics) assert [%Diagnostic.Result{}, %Diagnostic.Result{}] = diagnostics diff --git a/apps/expert/test/expert/progress_test.exs b/apps/expert/test/expert/progress_test.exs new file mode 100644 index 00000000..9eff891c --- /dev/null +++ b/apps/expert/test/expert/progress_test.exs @@ -0,0 +1,201 @@ +defmodule Expert.ProgressTest do + use ExUnit.Case + use Patch + + alias Expert.Progress + alias GenLSP.Notifications + alias GenLSP.Requests + alias GenLSP.Structures + + setup do + test_pid = self() + lsp = spawn(fn -> Process.sleep(:infinity) end) + + patch(Expert, :get_lsp, fn -> lsp end) + patch(Expert.Configuration, :client_support, fn :work_done_progress -> true end) + + # Mock GenLSP.request to return nil (success) and send the request to test process + patch(GenLSP, :request, fn ^lsp, request -> + send(test_pid, {:request, request}) + nil + end) + + # Mock GenLSP.notify to send the notification to test process + patch(GenLSP, :notify, fn ^lsp, notification -> + send(test_pid, {:notify, notification}) + :ok + end) + + on_exit(fn -> Process.exit(lsp, :kill) end) + + :ok + end + + describe "begin/2" do + test "generates a token and sends begin notification" do + assert {:ok, token} = Progress.begin("Building") + + assert is_integer(token) + + # Should request the client to create the progress + assert_received {:request, %Requests.WindowWorkDoneProgressCreate{params: params}} + assert params.token == token + + # Should send begin notification + assert_received {:notify, %Notifications.DollarProgress{params: params}} + assert params.token == token + assert %Structures.WorkDoneProgressBegin{} = params.value + assert params.value.title == "Building" + assert params.value.kind == "begin" + end + + test "passes options to begin notification" do + {:ok, _token} = Progress.begin("Building", message: "Starting...", percentage: 0) + + assert_received {:notify, %Notifications.DollarProgress{params: params}} + assert params.value.message == "Starting..." + assert params.value.percentage == 0 + end + + test "returns error when client rejects the token" do + patch(GenLSP, :request, fn _lsp, _request -> {:error, :rejected} end) + + assert {:error, :rejected} = Progress.begin("Building") + end + end + + describe "report/2" do + test "sends report notification" do + {:ok, token} = Progress.begin("Building") + # Clear the received messages + assert_received {:request, _} + assert_received {:notify, _} + + :ok = Progress.report(token, message: "Processing...") + + assert_received {:notify, %Notifications.DollarProgress{params: params}} + assert params.token == token + assert %Structures.WorkDoneProgressReport{} = params.value + assert params.value.message == "Processing..." + assert params.value.kind == "report" + end + + test "supports percentage option" do + {:ok, token} = Progress.begin("Building") + assert_received {:request, _} + assert_received {:notify, _} + + :ok = Progress.report(token, message: "Halfway", percentage: 50) + + assert_received {:notify, %Notifications.DollarProgress{params: params}} + assert params.value.percentage == 50 + end + end + + describe "complete/2" do + test "sends end notification" do + {:ok, token} = Progress.begin("Building") + assert_received {:request, _} + assert_received {:notify, _} + + :ok = Progress.complete(token, message: "Done!") + + assert_received {:notify, %Notifications.DollarProgress{params: params}} + assert params.token == token + assert %Structures.WorkDoneProgressEnd{} = params.value + assert params.value.message == "Done!" + assert params.value.kind == "end" + end + end + + describe "with_progress/3" do + test "wraps work with begin/complete" do + result = Progress.with_progress("Building", fn _token -> {:done, :ok} end) + + assert result == :ok + + # Should have begin notification + assert_received {:request, _} + assert_received {:notify, %Notifications.DollarProgress{params: begin_params}} + assert begin_params.value.kind == "begin" + + # Should have end notification + assert_received {:notify, %Notifications.DollarProgress{params: end_params}} + assert end_params.value.kind == "end" + end + + test "passes final message on completion" do + Progress.with_progress("Building", fn _token -> {:done, :ok, "Build complete"} end) + + assert_received {:request, _} + assert_received {:notify, _} + assert_received {:notify, %Notifications.DollarProgress{params: params}} + assert params.value.message == "Build complete" + end + + test "handles cancel result" do + result = Progress.with_progress("Building", fn _token -> {:cancel, :cancelled} end) + + assert result == :cancelled + + assert_received {:request, _} + assert_received {:notify, _} + assert_received {:notify, %Notifications.DollarProgress{params: params}} + assert params.value.message == "Cancelled" + end + + test "handles exceptions" do + assert_raise RuntimeError, "oops", fn -> + Progress.with_progress("Building", fn _token -> raise "oops" end) + end + + assert_received {:request, _} + assert_received {:notify, _} + assert_received {:notify, %Notifications.DollarProgress{params: params}} + assert params.value.message == "Error: oops" + end + + test "allows reporting during work" do + Progress.with_progress("Building", fn token -> + Progress.report(token, message: "Step 1") + Progress.report(token, message: "Step 2") + {:done, :ok} + end) + + assert_received {:request, _} + assert_received {:notify, %Notifications.DollarProgress{params: begin_params}} + assert begin_params.value.kind == "begin" + + assert_received {:notify, %Notifications.DollarProgress{params: report1}} + assert report1.value.message == "Step 1" + + assert_received {:notify, %Notifications.DollarProgress{params: report2}} + assert report2.value.message == "Step 2" + + assert_received {:notify, %Notifications.DollarProgress{params: end_params}} + assert end_params.value.kind == "end" + end + end + + describe "when client does not support progress" do + setup do + patch(Expert.Configuration, :client_support, fn :work_done_progress -> false end) + :ok + end + + test "begin returns noop token" do + assert {:ok, nil} = Progress.begin("Building") + + # Should NOT send any requests or notifications + refute_received {:request, _} + refute_received {:notify, _} + end + + test "with_progress executes the work" do + result = Progress.with_progress("Building", fn _token -> {:done, :ok} end) + + assert result == :ok + refute_received {:notify, _} + end + end +end diff --git a/apps/expert/test/expert/project/progress/state_test.exs b/apps/expert/test/expert/project/progress/state_test.exs deleted file mode 100644 index 4c87d4cf..00000000 --- a/apps/expert/test/expert/project/progress/state_test.exs +++ /dev/null @@ -1,72 +0,0 @@ -defmodule Expert.Project.Progress.StateTest do - alias Expert.Project.Progress.State - alias Expert.Project.Progress.Value - - import Forge.EngineApi.Messages - import Forge.Test.Fixtures - - use ExUnit.Case, async: true - - setup do - project = project() - {:ok, project: project} - end - - def progress(label, message \\ nil) do - project_progress(label: label, message: message) - end - - test "it should be able to add a begin event and put the new token", %{project: project} do - label = "mix deps.get" - state = project |> State.new() |> State.begin(progress(label)) - - assert %Value{token: token, title: ^label, kind: :begin} = state.progress_by_label[label] - assert token != nil - end - - test "it should be able to add a report event use the begin event token", %{project: project} do - label = "mix compile" - state = project |> State.new() |> State.begin(progress(label)) - - previous_token = state.progress_by_label[label].token - - %{progress_by_label: progress_by_label} = - State.report(state, progress(label, "lib/my_module.ex")) - - assert %Value{token: ^previous_token, message: "lib/my_module.ex", kind: :report} = - progress_by_label[label] - end - - test "clear the token_by_label after received a complete event", %{project: project} do - state = project |> State.new() |> State.begin(progress("mix compile")) - - %{progress_by_label: progress_by_label} = - State.complete(state, progress("mix compile", "in 2s")) - - assert progress_by_label == %{} - end - - test "set the progress value to nil when there is no begin event", %{ - project: project - } do - state = project |> State.new() |> State.report(progress("mix compile")) - assert state.progress_by_label["mix compile"] == nil - end - - test "set the progress value to nil when a complete event received before the report", %{ - project: project - } do - label = "mix compile" - - state = - project - |> State.new() - |> State.begin(progress(label)) - |> State.complete(progress(label, "in 2s")) - - %{progress_by_label: progress_by_label} = - State.report(state, progress(label, "lib/my_module.ex")) - - assert progress_by_label[label] == nil - end -end diff --git a/apps/expert/test/expert/project/progress/support_test.exs b/apps/expert/test/expert/project/progress/support_test.exs deleted file mode 100644 index ef1eabd0..00000000 --- a/apps/expert/test/expert/project/progress/support_test.exs +++ /dev/null @@ -1,33 +0,0 @@ -defmodule Expert.Project.Progress.SupportTest do - alias Expert.Project.Progress - - import Forge.EngineApi.Messages - import Forge.Test.Fixtures - - use ExUnit.Case - use Patch - use Progress.Support - - setup do - test_pid = self() - patch(Progress, :name, fn _ -> test_pid end) - :ok - end - - test "it should send begin/complete event and return the result" do - result = with_progress project(), "act", fn -> :ok end - - assert result == :ok - assert_received project_progress(label: "act", stage: :begin) - assert_received project_progress(label: "act", stage: :complete) - end - - test "it should send begin/complete event even there is an exception" do - assert_raise(Mix.Error, fn -> - with_progress project(), "start", fn -> raise Mix.Error, "can't start" end - end) - - assert_received project_progress(label: "start", stage: :begin) - assert_received project_progress(label: "start", stage: :complete) - end -end diff --git a/apps/expert/test/expert/project/progress_test.exs b/apps/expert/test/expert/project/progress_test.exs deleted file mode 100644 index e6e759a2..00000000 --- a/apps/expert/test/expert/project/progress_test.exs +++ /dev/null @@ -1,169 +0,0 @@ -defmodule Expert.Project.ProgressTest do - alias Expert.Configuration - alias Expert.EngineApi - alias Expert.Project - alias Expert.Test.DispatchFake - alias GenLSP.Notifications - alias GenLSP.Requests - alias GenLSP.Structures - - import Forge.Test.Fixtures - import Forge.EngineApi.Messages - import Expert.Test.Protocol.TransportSupport - - use ExUnit.Case - use Patch - use DispatchFake - use Forge.Test.EventualAssertions - - setup do - project = project() - pid = start_supervised!({Project.Progress, project}) - DispatchFake.start() - Engine.Dispatch.register_listener(pid, project_progress()) - Engine.Dispatch.register_listener(pid, percent_progress()) - - {:ok, project: project} - end - - def percent_begin(project, label, max) do - message = percent_progress(stage: :begin, label: label, max: max) - EngineApi.broadcast(project, message) - end - - defp percent_report(project, label, delta, message \\ nil) do - message = percent_progress(stage: :report, label: label, message: message, delta: delta) - EngineApi.broadcast(project, message) - end - - defp percent_complete(project, label, message) do - message = percent_progress(stage: :complete, label: label, message: message) - EngineApi.broadcast(project, message) - end - - def progress(stage, label, message \\ "") do - project_progress(label: label, message: message, stage: stage) - end - - def with_work_done_progress_support(_) do - patch(Configuration, :client_support, fn :work_done_progress -> true end) - :ok - end - - describe "report the progress message" do - setup [:with_patched_transport] - - test "it should be able to send the report progress", %{project: project} do - patch(Configuration, :client_support, fn :work_done_progress -> true end) - - begin_message = progress(:begin, "mix compile") - EngineApi.broadcast(project, begin_message) - - assert_receive {:transport, - %Requests.WindowWorkDoneProgressCreate{ - params: %Structures.WorkDoneProgressCreateParams{token: token} - }} - - assert_receive {:transport, %Notifications.DollarProgress{}} - - report_message = progress(:report, "mix compile", "lib/file.ex") - EngineApi.broadcast(project, report_message) - - assert_receive {:transport, - %Notifications.DollarProgress{ - params: %Structures.ProgressParams{token: ^token, value: value} - }} - - assert value.kind == "report" - assert value.message == "lib/file.ex" - assert value.percentage == nil - assert value.cancellable == nil - end - - test "it should write nothing when the client does not support work done", %{project: project} do - patch(Configuration, :client_support, fn :work_done_progress -> false end) - - begin_message = progress(:begin, "mix compile") - EngineApi.broadcast(project, begin_message) - - refute_receive {:transport, %Requests.WindowWorkDoneProgressCreate{params: %{}}} - end - end - - describe "reporting a percentage progress" do - setup [:with_patched_transport, :with_work_done_progress_support] - - test "it should be able to increment the percentage", %{project: project} do - percent_begin(project, "indexing", 400) - - assert_receive {:transport, %Requests.WindowWorkDoneProgressCreate{params: %{token: token}}} - assert_receive {:transport, %Notifications.DollarProgress{} = progress} - - assert progress.params.value.kind == "begin" - assert progress.params.value.title == "indexing" - assert progress.params.value.percentage == 0 - - percent_report(project, "indexing", 100) - - assert_receive {:transport, - %Notifications.DollarProgress{ - params: %Structures.ProgressParams{token: ^token, value: value} - }} - - assert value.kind == "report" - assert value.percentage == 25 - assert value.message == nil - - percent_report(project, "indexing", 260, "Almost done") - - assert_receive {:transport, - %Notifications.DollarProgress{params: %{token: ^token, value: value}}} - - assert value.percentage == 90 - assert value.message == "Almost done" - - percent_complete(project, "indexing", "Indexing Complete") - - assert_receive {:transport, - %Notifications.DollarProgress{params: %{token: ^token, value: value}}} - - assert value.kind == "end" - assert value.message == "Indexing Complete" - end - - test "it caps the percentage at 100", %{project: project} do - percent_begin(project, "indexing", 100) - percent_report(project, "indexing", 1000) - - assert_receive {:transport, - %Notifications.DollarProgress{params: %{value: %{kind: "begin"}}}} - - assert_receive {:transport, %Notifications.DollarProgress{params: %{value: value}}} - assert value.kind == "report" - assert value.percentage == 100 - end - - test "it only allows the percentage to grow", %{project: project} do - percent_begin(project, "indexing", 100) - - assert_receive {:transport, - %Notifications.DollarProgress{params: %{value: %{kind: "begin"}}}} - - percent_report(project, "indexing", 10) - - assert_receive {:transport, %Notifications.DollarProgress{params: %{value: value}}} - assert value.kind == "report" - assert value.percentage == 10 - - percent_report(project, "indexing", -10) - assert_receive {:transport, %Notifications.DollarProgress{params: %{value: value}}} - assert value.kind == "report" - assert value.percentage == 10 - - percent_report(project, "indexing", 5) - assert_receive {:transport, %Notifications.DollarProgress{params: %{value: value}}} - assert value.kind == "report" - assert value.percentage == 15 - end - end -end diff --git a/apps/forge/lib/forge/engine_api/messages.ex b/apps/forge/lib/forge/engine_api/messages.ex index f1d83b2d..1adfb96a 100644 --- a/apps/forge/lib/forge/engine_api/messages.ex +++ b/apps/forge/lib/forge/engine_api/messages.ex @@ -33,10 +33,6 @@ defmodule Forge.EngineApi.Messages do defrecord :file_diagnostics, project: nil, build_number: 0, uri: nil, diagnostics: [] - defrecord :project_progress, label: nil, message: nil, stage: :report - - defrecord :percent_progress, label: nil, message: nil, stage: :report, max: 0, delta: 0 - defrecord :struct_discovered, module: nil, fields: [] defrecord :project_index_ready, project: nil @@ -116,13 +112,6 @@ defmodule Forge.EngineApi.Messages do diagnostics: diagnostics() ) - @type project_progress :: - record(:project_progress, - label: String.t(), - message: String.t() | integer(), - stage: :prepare | :begin | :report | :complete - ) - @type struct_discovered :: record(:struct_discovered, module: module(), fields: field_list()) @type project_index_ready :: record(:project_index_ready, project: Forge.Project.t()) diff --git a/apps/forge/lib/forge/namespace/transform/beams.ex b/apps/forge/lib/forge/namespace/transform/beams.ex index ca44c363..418fea3c 100644 --- a/apps/forge/lib/forge/namespace/transform/beams.ex +++ b/apps/forge/lib/forge/namespace/transform/beams.ex @@ -46,21 +46,40 @@ defmodule Forge.Namespace.Transform.Beams do defp changed?(same, same), do: false defp changed?(_, _), do: true - defp block_until_done(same, same) do - Mix.Shell.IO.info("\n done") - end + defp block_until_done(same, same, last_write_time \\ nil) + + defp block_until_done(same, same, _last_write_time), do: Mix.Shell.IO.info("\n done") - defp block_until_done(current, max) do + defp block_until_done(current, max, last_write_time) do receive do :progress -> :ok end current = current + 1 + + last_write_time = log_completion_debounced(current, max, last_write_time) + + block_until_done(current, max, last_write_time) + end + + defp log_completion_debounced(current, max, last_write_time) when is_integer(last_write_time) do + now = :erlang.monotonic_time(:millisecond) + + if now - last_write_time >= 66 do + IO.write("\r") + IO.write("Applying namespace: #{format_percent(current, max)} complete") + + now + else + last_write_time + end + end + + defp log_completion_debounced(current, max, _last_write_time) do IO.write("\r") - percent_complete = format_percent(current, max) + IO.write("Applying namespace: #{format_percent(current, max)} complete") - IO.write(" Applying namespace: #{percent_complete} complete") - block_until_done(current, max) + :erlang.monotonic_time(:millisecond) end defp apply_and_update_progress(beam_file, caller) do @@ -109,7 +128,7 @@ defmodule Forge.Namespace.Transform.Beams do defp format_percent(current, max) do int_val = (current / max * 100) - |> round() + |> floor() |> Integer.to_string() String.pad_leading("#{int_val}%", 4) diff --git a/apps/forge/lib/forge/progress.ex b/apps/forge/lib/forge/progress.ex new file mode 100644 index 00000000..9db1b2de --- /dev/null +++ b/apps/forge/lib/forge/progress.ex @@ -0,0 +1,130 @@ +defmodule Forge.Progress do + @moduledoc """ + Behaviour for progress reporting. + + ## Usage + + defmodule MyProgress do + use Forge.Progress + + @impl true + def begin(title, opts), do: ... + + @impl true + def report(token, opts), do: ... + + @impl true + def complete(token, opts), do: ... + end + """ + + @type token :: integer() | String.t() + @type work_result :: {:done, term()} | {:done, term(), String.t()} | {:cancel, term()} + + @callback begin(title :: String.t(), opts :: keyword()) :: {:ok, token()} | {:error, :rejected} + @callback report(token :: token(), opts :: keyword()) :: :ok + @callback complete(token :: token(), opts :: keyword()) :: :ok + + defmacro __using__(_opts) do + quote do + @behaviour Forge.Progress + + alias Forge.Progress.Tracker + + @noop_token nil + + def noop_token, do: @noop_token + + defguardp is_token(token) when is_binary(token) or is_integer(token) + + @doc """ + Wraps work with progress reporting. + + The `work_fn` receives the progress token and should return one of: + - `{:done, result}` - Operation completed successfully + - `{:done, result, message}` - Completed with a final message + - `{:cancel, result}` - Operation was cancelled + + ## Options + + - `:message` - Initial status message (optional) + - `:percentage` - Initial percentage 0-100 (optional) + - `:cancellable` - Whether the client can cancel (default: false) + """ + def with_progress(title, work_fn, opts \\ []) when is_function(work_fn, 1) do + run_with_progress(title, opts, work_fn) + end + + @doc """ + Wraps work with tracked progress reporting via an ephemeral GenServer. + + Safely handles concurrent updates and fires a callback on each update. + Useful when you need to track progress across concurrent tasks. + + The work function receives a `report` function that accepts: + - `:message` - Status message + - `:add` - Amount to increment the counter + + Uses a default callback that reports percentage-based progress. + """ + def with_tracked_progress(title, total, work_fn) when is_function(work_fn, 1) do + with_tracked_progress(title, total, work_fn, &default_report/4) + end + + def with_tracked_progress(title, total, work_fn, report_fn) + when is_function(work_fn, 1) and is_function(report_fn, 4) do + run_with_progress(title, [percentage: 0], fn token -> + {:ok, tracker} = Tracker.start_link(token: token, total: total, report_fn: report_fn) + + try do + work_fn.(fn opts -> Tracker.add(tracker, Keyword.get(opts, :add, 0), opts) end) + after + Tracker.stop(tracker) + end + end) + end + + defp run_with_progress(title, opts, work_fn) do + case begin(title, opts) do + {:ok, token} -> execute_work(token, work_fn) + {:error, :rejected} -> elem(work_fn.(@noop_token), 1) + end + end + + defp execute_work(token, work_fn) do + try do + token |> work_fn.() |> complete_with(token) + rescue + e -> + complete(token, message: "Error: #{Exception.message(e)}") + reraise e, __STACKTRACE__ + end + end + + defp complete_with({:done, result}, token) do + complete(token, []) + result + end + + defp complete_with({:done, result, msg}, token) do + complete(token, message: msg) + result + end + + defp complete_with({:cancel, result}, token) do + complete(token, message: "Cancelled") + result + end + + defp default_report(message, current, total, token) do + percentage = if total > 0, do: min(100, div(current * 100, total)), else: 0 + report(token, message: message, percentage: percentage) + end + + defoverridable with_progress: 2, + with_progress: 3, + with_tracked_progress: 3, + with_tracked_progress: 4 + end + end +end diff --git a/apps/forge/lib/forge/progress/tracker.ex b/apps/forge/lib/forge/progress/tracker.ex new file mode 100644 index 00000000..ed1334bb --- /dev/null +++ b/apps/forge/lib/forge/progress/tracker.ex @@ -0,0 +1,67 @@ +defmodule Forge.Progress.Tracker do + @moduledoc """ + Ephemeral GenServer for tracking progress across concurrent tasks. + + This module provides a stateful progress tracker that can be safely + updated from multiple concurrent processes (e.g., Task.async_stream). + It fires a callback on each update to report progress to the LSP client. + + Use via `Forge.Progress.with_tracked_progress/4,5` rather than directly. + """ + + use GenServer + + defstruct [:token, :total, :current, :report_fn] + + # Client API + + @doc """ + Starts a tracker process. + + ## Options + + - `:token` - The progress token (required) + - `:total` - The total value representing 100% (required) + - `:report_fn` - Callback invoked on each update (required) + Signature: `(message, current, total, token) -> any()` + """ + def start_link(opts), do: GenServer.start_link(__MODULE__, opts) + + @doc """ + Adds delta to the current progress and fires the report callback. + + ## Options + + - `:message` - Status message to pass to the callback + """ + def add(tracker, delta, opts \\ []), do: GenServer.cast(tracker, {:add, delta, opts}) + + @doc """ + Stops the tracker process. + """ + def stop(tracker), do: GenServer.stop(tracker, :normal) + + # Server callbacks + + @impl GenServer + def init(opts) do + state = %__MODULE__{ + token: Keyword.fetch!(opts, :token), + total: Keyword.fetch!(opts, :total), + current: 0, + report_fn: Keyword.fetch!(opts, :report_fn) + } + + {:ok, state} + end + + @impl GenServer + def handle_cast({:add, delta, opts}, state) do + new_current = state.current + delta + message = Keyword.get(opts, :message) + + state.report_fn.(message, new_current, state.total, state.token) + + {:noreply, %{state | current: new_current}} + end +end