├── .formatter.exs ├── .gitignore ├── README.md ├── lib ├── mix │ └── tasks │ │ └── why_did_recompile.ex └── why_did_recompile │ ├── analyzer.ex │ ├── dep.ex │ └── xref_plain_format_parser.ex ├── mix.exs └── test ├── test_helper.exs ├── why_did_recompile ├── analyzer_test.exs └── xref_plain_format_parser_test.exs └── why_did_recompile_test.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | why_did_recompile-*.tar 24 | 25 | 26 | # Temporary files for e.g. tests 27 | /tmp 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WhyDidRecompile 2 | 3 | Why did some files get recompiled after I made a nonsubstantial change to a single file? 4 | 5 | As an Elixir project becomes larger, recompilation during development can become slower and slower, 6 | because compile time dependencies creep in. Elixir's own `mix xref` task can help to analyze the 7 | situation, but identifying the dependencies involved can still be tedious. 8 | 9 | This package contains a mix task that can answer the initial question. 10 | 11 | ## Installation 12 | 13 | Install as an archive: 14 | 15 | ``` 16 | mix archive.install github schnittchen/why_did_recompile 17 | ``` 18 | 19 | 35 | 36 | ## Usage 37 | 38 | ``` 39 | mix why_did_recompile --compiled=lib/my_project/a.ex --changed=lib/my_project/b.ex 40 | ``` 41 | 42 | will print out, if possible, a dependency chain from a.ex to b.ex that requires a.ex to recompile 43 | when b.ex changes. 44 | 45 | ## Scope 46 | 47 | Currently, export dependencies are treated as runtime dependencies. 48 | -------------------------------------------------------------------------------- /lib/mix/tasks/why_did_recompile.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.WhyDidRecompile do 2 | @moduledoc """ 3 | Run as `mix why_did_recompile --compiled=path1 --changed=path2` where `path1` is a file that 4 | is recompiled when a trivial change to `path2` is made. If a transitive compile time 5 | dependency is found that explains why, that dependency chain is printed out. 6 | 7 | Runs `mix xref` and analyzes its output. 8 | """ 9 | @shortdoc "Helps analyzing source file dependencies to understand recompilations" 10 | 11 | use Mix.Task 12 | 13 | @impl Mix.Task 14 | def run(args) do 15 | {opts, _, _} = OptionParser.parse(args, switches: [compiled: :string, changed: :string]) 16 | 17 | case opts |> Enum.sort() |> Keyword.split([:compiled, :changed]) do 18 | {[changed: changed, compiled: compiled], []} -> do_run(changed, compiled) 19 | {_, [_ | _] = excess} -> raise "Excess argument(s): #{inspect(excess)}" 20 | _else -> raise "Missing arguments, required: --compiled=path1 --changed=path2" 21 | end 22 | end 23 | 24 | alias WhyDidRecompile.{XrefPlainFormatParser, Analyzer} 25 | 26 | defp do_run(changed, compiled) do 27 | with( 28 | {:ok, output} <- xref_output(), 29 | deps_by_path = XrefPlainFormatParser.call(output), 30 | :ok <- check_path_given(deps_by_path, compiled), 31 | :ok <- check_path_given(deps_by_path, changed) 32 | ) do 33 | Analyzer.find_compile_dep_chain(deps_by_path, source: compiled, target: changed) 34 | |> case do 35 | nil -> 36 | Mix.shell().info("Could not find a transitive compile time dependency for you.") 37 | 38 | result -> 39 | Mix.shell().info("Found a transitive compile time dependency for you:") 40 | 41 | IO.inspect(result) 42 | end 43 | end 44 | end 45 | 46 | defp xref_output do 47 | Mix.shell().info("Running mix deps, this can take a while...") 48 | 49 | case System.cmd("mix", ["xref", "graph", "--format=plain"]) do 50 | {output, 0} -> {:ok, output} 51 | _else -> :error 52 | end 53 | end 54 | 55 | defp check_path_given(deps_by_path, path) do 56 | unless Map.has_key?(deps_by_path, path) do 57 | raise "path #{path} not found in output of `mix xref`" 58 | end 59 | 60 | :ok 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/why_did_recompile/analyzer.ex: -------------------------------------------------------------------------------- 1 | defmodule WhyDidRecompile.Analyzer do 2 | def find_compile_dep_chain(deps_by_path, opts) do 3 | {opts, []} = Keyword.split(opts, [:source, :target]) 4 | %{source: source, target: target} = Map.new(opts) 5 | 6 | find_dep_chain(deps_by_path, source, target, [], true) 7 | |> case do 8 | nil -> 9 | nil 10 | 11 | deps -> 12 | deps 13 | |> Enum.map(fn dep -> {dep.path, dep.kind} end) 14 | |> case do 15 | list -> 16 | [{source, :initial}] ++ list 17 | end 18 | end 19 | end 20 | 21 | defp find_dep_chain(deps_by_path, source_path, target_path, seen, first_step?) do 22 | Map.fetch!(deps_by_path, source_path) 23 | |> Enum.reject(&(&1.path in seen)) 24 | |> Enum.filter(fn dep -> 25 | dep.kind == :compile || !first_step? 26 | end) 27 | |> Enum.find_value(fn %{path: next_path} = dep -> 28 | if next_path == target_path do 29 | [dep] 30 | else 31 | seen = [next_path | seen] 32 | 33 | case find_dep_chain(deps_by_path, next_path, target_path, seen, false) do 34 | nil -> nil 35 | chain -> [dep | chain] 36 | end 37 | end 38 | end) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/why_did_recompile/dep.ex: -------------------------------------------------------------------------------- 1 | defmodule WhyDidRecompile.Dep do 2 | @moduledoc """ 3 | A dependency. `kind` is in `[:compile, :export, :runtime]`. 4 | """ 5 | 6 | defstruct [:path, :kind] 7 | end 8 | -------------------------------------------------------------------------------- /lib/why_did_recompile/xref_plain_format_parser.ex: -------------------------------------------------------------------------------- 1 | defmodule WhyDidRecompile.XrefPlainFormatParser do 2 | alias WhyDidRecompile.Dep 3 | 4 | def call(binary) do 5 | String.split(binary, "\n") 6 | |> Enum.reject(&(&1 == "")) 7 | |> Enum.chunk_while( 8 | [], 9 | fn line, acc -> 10 | case line do 11 | <<_>> <> "-- " <> _rest -> 12 | {:cont, [line | acc]} 13 | 14 | _else -> 15 | {:cont, acc, [line]} 16 | end 17 | end, 18 | fn acc -> {:cont, acc, nil} end 19 | ) 20 | |> Enum.reject(&(&1 == [])) 21 | |> Enum.map(fn chunk -> 22 | [head | rest] = Enum.reverse(chunk) 23 | 24 | rest 25 | |> Enum.map(fn line -> 26 | case String.split(line, " ") do 27 | [_arrow, path] -> 28 | %Dep{path: path, kind: :runtime} 29 | 30 | [_arrow, path, "(export)"] -> 31 | %Dep{path: path, kind: :export} 32 | 33 | [_arrow, path, "(compile)"] -> 34 | %Dep{path: path, kind: :compile} 35 | end 36 | end) 37 | |> case do 38 | deps -> 39 | {head, deps} 40 | end 41 | end) 42 | |> Map.new() 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule WhyDidRecompile.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :why_did_recompile, 7 | version: "0.1.0", 8 | elixir: "~> 1.11", 9 | deps: deps() 10 | ] 11 | end 12 | 13 | # Run "mix help deps" to learn about dependencies. 14 | defp deps do 15 | [ 16 | # {:dep_from_hexpm, "~> 0.3.0"}, 17 | # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}, 18 | # {:sibling_app_in_umbrella, in_umbrella: true} 19 | ] 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /test/why_did_recompile/analyzer_test.exs: -------------------------------------------------------------------------------- 1 | defmodule WhyDidRecompile.AnalyzerTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias WhyDidRecompile.{Analyzer, Dep} 5 | 6 | # Note `deps_by_path/1` generates a deps map from a string, where 7 | # * "a -> b" means that a has a runtime dependency on b 8 | # * "a => b" means that a has a compile time dependency on b 9 | # * "S" is the source and "T" is the target when calling find_compile_dep_chain 10 | 11 | describe "find_compile_dep_chain" do 12 | test "returns nil when nothing found" do 13 | deps_by_path = deps_by_path("S -> 1 -> T") 14 | 15 | assert find_compile_dep_chain(deps_by_path) == nil 16 | 17 | deps_by_path = deps_by_path("1 => S -> T") 18 | 19 | assert find_compile_dep_chain(deps_by_path) == nil 20 | 21 | deps_by_path = deps_by_path("S -> 1 => T") 22 | 23 | assert find_compile_dep_chain(deps_by_path) == nil 24 | 25 | deps_by_path = deps_by_path("S -> 1 -> T => 2") 26 | 27 | assert find_compile_dep_chain(deps_by_path) == nil 28 | 29 | deps_by_path = 30 | """ 31 | 1 -> 2 => S -> 1 32 | 1 -> T 33 | """ 34 | |> deps_by_path 35 | 36 | assert find_compile_dep_chain(deps_by_path) == nil 37 | end 38 | 39 | test "returns shortest thinkable chain" do 40 | deps_by_path = deps_by_path("S => T") 41 | 42 | assert find_compile_dep_chain(deps_by_path) == [ 43 | {"S", :initial}, 44 | {"T", :compile} 45 | ] 46 | end 47 | 48 | test "returns chain starting with compile time dep" do 49 | deps_by_path = deps_by_path("S => 1 -> T") 50 | 51 | assert find_compile_dep_chain(deps_by_path) == [ 52 | {"S", :initial}, 53 | {"1", :compile}, 54 | {"T", :runtime} 55 | ] 56 | end 57 | 58 | test "returns chain involving loop" do 59 | deps_by_path = deps_by_path("S => 1 -> S -> T") 60 | 61 | assert find_compile_dep_chain(deps_by_path) == [ 62 | {"S", :initial}, 63 | {"1", :compile}, 64 | {"S", :runtime}, 65 | {"T", :runtime} 66 | ] 67 | end 68 | 69 | defp find_compile_dep_chain(deps_by_path) do 70 | Analyzer.find_compile_dep_chain(deps_by_path, source: "S", target: "T") 71 | end 72 | 73 | defp deps_by_path(binary) do 74 | binary 75 | |> String.split("\n") 76 | |> Enum.flat_map(fn line -> 77 | String.split(line) 78 | |> Enum.chunk_every(3, 2, :discard) 79 | |> Enum.map(fn [from, arrow, to] -> 80 | kind = 81 | case arrow do 82 | "->" -> :runtime 83 | "=>" -> :compile 84 | end 85 | 86 | {from, %Dep{kind: kind, path: to}} 87 | end) 88 | end) 89 | |> Enum.group_by(fn {from, _} -> from end, fn {_, dep} -> dep end) 90 | |> case do 91 | map -> 92 | map 93 | |> Map.values() 94 | |> Enum.flat_map(& &1) 95 | |> Enum.reduce(map, fn %{path: path}, map -> Map.put_new(map, path, []) end) 96 | end 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /test/why_did_recompile/xref_plain_format_parser_test.exs: -------------------------------------------------------------------------------- 1 | defmodule WhyDidRecompile.XrefPlainFormatParserTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias WhyDidRecompile.{XrefPlainFormatParser, Dep} 5 | 6 | test "returns deps by path" do 7 | output = """ 8 | lib/why_did_recompile.ex 9 | lib/why_did_recompile/dep.ex 10 | lib/why_did_recompile/xref_plain_format_parser.ex 11 | `-- lib/why_did_recompile/dep.ex (compile) 12 | """ 13 | 14 | result = XrefPlainFormatParser.call(output) 15 | 16 | expected = %{ 17 | "lib/why_did_recompile.ex" => [], 18 | "lib/why_did_recompile/dep.ex" => [], 19 | "lib/why_did_recompile/xref_plain_format_parser.ex" => [ 20 | %Dep{kind: :compile, path: "lib/why_did_recompile/dep.ex"} 21 | ] 22 | } 23 | 24 | assert result == expected 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/why_did_recompile_test.exs: -------------------------------------------------------------------------------- 1 | defmodule WhyDidRecompileTest do 2 | use ExUnit.Case 3 | doctest WhyDidRecompile 4 | 5 | test "greets the world" do 6 | assert WhyDidRecompile.hello() == :world 7 | end 8 | end 9 | --------------------------------------------------------------------------------