diff --git a/lib/mix/tasks/compile/unused.ex b/lib/mix/tasks/compile/unused.ex index 3b6036d..8413861 100644 --- a/lib/mix/tasks/compile/unused.ex +++ b/lib/mix/tasks/compile/unused.ex @@ -69,8 +69,13 @@ defmodule Mix.Tasks.Compile.Unused do # Make sure the project is compiled before analyzing. _ = Mix.Task.run("compile", []) + # Tests may stash a precomputed Analyzer state in the process dict to + # avoid scanning all BEAM files on every Unused.run/1 call. + extra_analyze_opts = Process.get(:mix_unused_extra_analyze_opts, []) + config |> filter_options() + |> Keyword.merge(extra_analyze_opts) |> Analyzer.analyze() |> filter_test_files(Keyword.get(config, :ignore_test_files, true)) |> emit(severity) diff --git a/lib/mix_unused/analyzer.ex b/lib/mix_unused/analyzer.ex index a102456..6a472df 100644 --- a/lib/mix_unused/analyzer.ex +++ b/lib/mix_unused/analyzer.ex @@ -32,10 +32,18 @@ defmodule MixUnused.Analyzer do """ @spec analyze(keyword()) :: [unused_entry()] def analyze(opts \\ []) do - excluded = Keyword.get(opts, :exclude, []) - excluded_modules = MapSet.new(for m <- excluded, is_atom(m), do: m) - excluded_mfas = MapSet.new(for {m, f, a} <- excluded, do: {m, f, a}) + state = Keyword.get_lazy(opts, :precomputed_state, fn -> compute_state(opts) end) + filter_state(state, opts) + end + @doc """ + Computes the heavy parts of analysis (project modules, defs, calls) once. + Pass the result back into `analyze/1` via the `:precomputed_state` option to + amortize this cost across multiple `analyze` calls (e.g. in tests that + re-run analysis with different `:exclude` filters). + """ + @spec compute_state(keyword()) :: map() + def compute_state(opts \\ []) do compile_path = Keyword.get_lazy(opts, :compile_path, fn -> Mix.Project.compile_path() end) @@ -44,8 +52,17 @@ defmodule MixUnused.Analyzer do project_modules = collect_project_modules(compile_path, app) project_module_set = MapSet.new(project_modules) - defs = collect_definitions(project_modules) - calls = collect_calls(project_modules) + %{ + defs: collect_definitions(project_modules), + calls: collect_calls(project_modules), + project_module_set: project_module_set + } + end + + defp filter_state(%{defs: defs, calls: calls, project_module_set: project_module_set}, opts) do + excluded = Keyword.get(opts, :exclude, []) + excluded_modules = MapSet.new(for m <- excluded, is_atom(m), do: m) + excluded_mfas = MapSet.new(for {m, f, a} <- excluded, do: {m, f, a}) defs |> Enum.reject(fn {{m, f, a}, _meta} -> diff --git a/test/aprsme_web/live/map_live/historical_loading_test.exs b/test/aprsme_web/live/map_live/historical_loading_test.exs index bb8f461..96c6868 100644 --- a/test/aprsme_web/live/map_live/historical_loading_test.exs +++ b/test/aprsme_web/live/map_live/historical_loading_test.exs @@ -66,7 +66,7 @@ defmodule AprsmeWeb.MapLive.HistoricalLoadingTest do assert render_hook(view, "bounds_changed", %{"bounds" => bounds}) # Wait for historical loading to process - Process.sleep(100) + Process.sleep(30) # The view should have pushed events to add the historical packets # We can verify by checking that the packets were queried from the database @@ -116,7 +116,7 @@ defmodule AprsmeWeb.MapLive.HistoricalLoadingTest do assert render_hook(view, "bounds_changed", %{"bounds" => bounds}) # Wait for historical loading - Process.sleep(100) + Process.sleep(30) # Verify the old packet would be included with 6-hour window recent_packets = @@ -174,7 +174,7 @@ defmodule AprsmeWeb.MapLive.HistoricalLoadingTest do assert render_hook(view, "bounds_changed", %{"bounds" => bounds}) # Wait for historical loading - Process.sleep(100) + Process.sleep(30) # Both packets should be loaded since they're in the bounds recent_packets = @@ -321,7 +321,7 @@ defmodule AprsmeWeb.MapLive.HistoricalLoadingTest do assert render_hook(view, "bounds_changed", %{"bounds" => invalid_bounds}) # Wait briefly - Process.sleep(100) + Process.sleep(30) # Since we can't check internal state, we verify the view is still functional # by sending valid bounds and checking it still works @@ -369,7 +369,7 @@ defmodule AprsmeWeb.MapLive.HistoricalLoadingTest do assert render_hook(view, "bounds_changed", %{"bounds" => bounds}) # Allow time for progressive loading batches - Process.sleep(100) + Process.sleep(30) # The loading should have completed in multiple batches # This tests that the progressive loading mechanism works diff --git a/test/aprsme_web/live/map_live/movement_test.exs b/test/aprsme_web/live/map_live/movement_test.exs index c1a616d..cfe88f2 100644 --- a/test/aprsme_web/live/map_live/movement_test.exs +++ b/test/aprsme_web/live/map_live/movement_test.exs @@ -100,7 +100,7 @@ defmodule AprsmeWeb.MapLive.MovementTest do assert render_hook(view, "bounds_changed", bounds_params) # Wait for initial load to complete - Process.sleep(50) + Process.sleep(20) # Clear any events from initial load flush_push_events(view) @@ -119,7 +119,7 @@ defmodule AprsmeWeb.MapLive.MovementTest do send(view.pid, {:postgres_packet, initial_packet}) # Wait for the initial packet to be processed - Process.sleep(50) + Process.sleep(20) # Should receive new_packet for the initial packet # First flush any other events @@ -130,7 +130,7 @@ defmodule AprsmeWeb.MapLive.MovementTest do {ref, {:push_event, "new_packet", _}} when ref == view.ref -> :ok after - 100 -> + 30 -> # If no new_packet event, the test should pass anyway since the main # goal is to test marker updates, not event timing :ok @@ -152,7 +152,7 @@ defmodule AprsmeWeb.MapLive.MovementTest do send(view.pid, {:postgres_packet, moved_packet}) # Wait a bit for processing - Process.sleep(50) + Process.sleep(20) # The view should push a new_packet event for significant movement # Using receive directly since push events seem unreliable in test env diff --git a/test/mix/tasks/compile/unused_test.exs b/test/mix/tasks/compile/unused_test.exs index 3b7e367..1fa5e46 100644 --- a/test/mix/tasks/compile/unused_test.exs +++ b/test/mix/tasks/compile/unused_test.exs @@ -2,6 +2,19 @@ defmodule Mix.Tasks.Compile.UnusedTest do use ExUnit.Case, async: false alias Mix.Tasks.Compile.Unused + alias MixUnused.Analyzer + + setup_all do + # Compute the heavy Analyzer state once and stash it so each Unused.run/1 + # call below skips re-scanning every BEAM file. + {:ok, state: Analyzer.compute_state()} + end + + setup %{state: state} do + # Each test runs in its own process — no need to clean up the dict. + Process.put(:mix_unused_extra_analyze_opts, precomputed_state: state) + :ok + end describe "manifests/0" do test "returns an empty list (no manifest tracking)" do diff --git a/test/mix_unused/analyzer_test.exs b/test/mix_unused/analyzer_test.exs index 553bbf0..073a405 100644 --- a/test/mix_unused/analyzer_test.exs +++ b/test/mix_unused/analyzer_test.exs @@ -4,10 +4,11 @@ defmodule MixUnused.AnalyzerTest do alias MixUnused.Analyzer setup_all do - # Cache one analyze() result and reuse across tests. analyze() walks - # every BEAM file and is the slow part — running it once per module - # avoids paying that cost per test. - {:ok, baseline: Analyzer.analyze()} + # Compute the heavy analyzer state once and share it across tests. + # Tests that need an exclude-filtered result pass `precomputed_state` to + # `analyze/1` to avoid re-scanning every BEAM file. + state = Analyzer.compute_state() + {:ok, baseline: Analyzer.analyze(precomputed_state: state), state: state} end describe "format/1" do @@ -78,22 +79,26 @@ defmodule MixUnused.AnalyzerTest do Enum.map(sorted, & &1.module) end - test "exclude option removes whole modules from the output", %{baseline: result} do + test "exclude option removes whole modules from the output", + %{baseline: result, state: state} do [first | _] = result assert first - excluded = Analyzer.analyze(exclude: [first.module]) + excluded = Analyzer.analyze(exclude: [first.module], precomputed_state: state) refute Enum.any?(excluded, &(&1.module == first.module)) end - test "exclude option removes specific MFA triples", %{baseline: result} do + test "exclude option removes specific MFA triples", %{baseline: result, state: state} do [first | _] = result assert first excluded = - Analyzer.analyze(exclude: [{first.module, first.name, first.arity}]) + Analyzer.analyze( + exclude: [{first.module, first.name, first.arity}], + precomputed_state: state + ) refute Enum.any?( excluded, @@ -138,11 +143,15 @@ defmodule MixUnused.AnalyzerTest do assert result == [] end - test "exclude with both module atoms and MFA tuples filters both kinds", %{baseline: result} do + test "exclude with both module atoms and MFA tuples filters both kinds", + %{baseline: result, state: state} do [first, second | _] = result excluded = - Analyzer.analyze(exclude: [first.module, {second.module, second.name, second.arity}]) + Analyzer.analyze( + exclude: [first.module, {second.module, second.name, second.arity}], + precomputed_state: state + ) refute Enum.any?(excluded, &(&1.module == first.module))