├── .gitignore ├── LICENSE ├── README.md ├── config └── config.exs ├── lib └── module_dependency_visualizer.ex ├── mix.exs ├── run.exs └── test ├── module_dependency_visualizer_test.exs └── test_helper.exs /.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 3rd-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 | module_dependency_visualizer-*.tar 24 | 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Devon C. Estes 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ModuleDependencyVisualizer 2 | 3 | ## What is this? 4 | 5 | I needed a way to generate a graph of module dependencies for Elixir 6 | applications for an upcoming talk I'm giving. This tool generates that 7 | structured data for me so I can investigate and make pretty graphs! 8 | 9 | A module dependency, for this purpose, is any module that is `include`d, `use`d, 10 | or has a function called. You can check out the tests for a brief example that 11 | should clear things up. 12 | 13 | ## Can/Should I use this? 14 | 15 | Sure, why not! It's a very narrow library that I'm mostly have as open sourced 16 | so the code is available for inspection by people who see the talk I'm giving 17 | that will feature the data generated by this tool. 18 | 19 | ## How can I use this? 20 | 21 | If you really want to check out the dependency graph for your Elixir 22 | application, go ahead and clone this repo. Then, in `run.exs` you can insert 23 | the path to whatever files you want to analyze in the string there. Then run the 24 | app using `mix run run.exs`. You'll want to have Graphviz already installed on 25 | your machine if you want to see the pretty graph. 26 | 27 | If you want to do really deep analysis of your graphs, you can use 28 | [Gephi](https://gephi.org/). The `.gv` files output by this program can easily 29 | be loaded into that program. 30 | 31 | Also, I've only tested this on OSX, so Linux and Windows may have some 32 | funkiness. 33 | 34 | ## Is it any good? 35 | 36 | No, it most certainly is not. Tests are sparse, documentation is lacking, and 37 | the code itself has a ton of duplication. It is, however, a working application 38 | without any dependencies. 39 | 40 | ## Will it be developed further? 41 | 42 | No, it will not. If you're interested in developing this, feel free to fork this 43 | and take it in whichever direction you see fit! This fits my needs for now, and 44 | at the moment I'm too busy to commit to maintaining it. 45 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure your application as: 12 | # 13 | # config :module_dependency_visualizer, key: :value 14 | # 15 | # and access this configuration in your application as: 16 | # 17 | # Application.get_env(:module_dependency_visualizer, :key) 18 | # 19 | # You can also configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env}.exs" 31 | -------------------------------------------------------------------------------- /lib/module_dependency_visualizer.ex: -------------------------------------------------------------------------------- 1 | defmodule ModuleDependencyVisualizer do 2 | @moduledoc """ 3 | This is the public interface for this simple little tool to parse a file or 4 | list of files for dependencies between modules. It will use the `dot` command 5 | to generate a graph PNG for us thanks to graphviz. 6 | """ 7 | 8 | @doc """ 9 | Analyzes a given list of file paths (absolute or relative), creates the 10 | necessary Graphviz file, and then creates the graph and opens it. 11 | """ 12 | @spec run(list) :: :ok 13 | def run(file_paths) do 14 | file_paths 15 | |> analyze 16 | |> create_gv_file 17 | |> create_and_open_graph 18 | 19 | :ok 20 | end 21 | 22 | @doc """ 23 | This will accept a list of file paths (absolute or relative), read each of 24 | those files, and return a keyword list of all the module dependencies in 25 | all those files. The output looks something like this: 26 | 27 | [{"ModuleName", "String"}, {"ModuleName", "lists"}] 28 | 29 | The lowercase modules are Erlang modules, and the camelcase modules are all 30 | Elixir modules. 31 | """ 32 | @spec analyze([String.t()]) :: [{String.t(), String.t()}] 33 | def analyze(file_paths) when is_list(file_paths) do 34 | Enum.flat_map(file_paths, fn file_path -> 35 | {:ok, file} = File.read(file_path) 36 | analyze(file) 37 | end) 38 | end 39 | 40 | @doc """ 41 | Analyzes a single file for dependencies between modules. This is the real meat 42 | of this tool. After this is done, then it's just formatting the graphviz file 43 | correctly and that's pretty easy. 44 | """ 45 | @spec analyze(String.t()) :: [{String.t(), String.t()}] 46 | def analyze(file) when is_binary(file) do 47 | {:ok, ast} = Code.string_to_quoted(file) 48 | 49 | {_, all_modules} = 50 | Macro.postwalk(ast, [], fn 51 | ast = {:defmodule, _meta, module_ast}, modules -> 52 | {ast, modules ++ [deps_for_module(module_ast)]} 53 | 54 | ast, modules -> 55 | {ast, modules} 56 | end) 57 | 58 | List.flatten(all_modules) 59 | end 60 | 61 | defp deps_for_module(ast) do 62 | {_, dependencies} = 63 | Macro.postwalk(ast, [], fn 64 | ast = {:., _meta, [module, _]}, modules when is_atom(module) -> 65 | {ast, modules ++ [[module]]} 66 | 67 | ast = {:., _meta, [{:__aliases__, _, module_info}, _]}, modules -> 68 | {ast, modules ++ [module_info]} 69 | 70 | ast, modules -> 71 | {ast, modules} 72 | end) 73 | 74 | {_, [dependent | other_modules]} = 75 | Macro.postwalk(ast, [], fn 76 | ast = {:__aliases__, _meta, module_info}, modules -> 77 | {ast, modules ++ [module_info]} 78 | 79 | ast, modules -> 80 | {ast, modules} 81 | end) 82 | 83 | {_, alias_info} = 84 | Macro.postwalk(ast, [], fn 85 | ast = {:alias, _meta, info}, aliases -> 86 | {ast, aliases ++ [info]} 87 | 88 | ast = {:require, _, alias_info = [{:__aliases__, _, _}, [as: {:__aliases__, _, _}]]}, 89 | aliases -> 90 | {ast, aliases ++ [alias_info]} 91 | 92 | ast, aliases -> 93 | {ast, aliases} 94 | end) 95 | 96 | total_modules = Enum.uniq(dependencies ++ other_modules) 97 | 98 | total_modules 99 | |> reconcile_aliases(alias_info) 100 | |> Enum.map(fn module_info -> 101 | {format_module(dependent), format_module(module_info)} 102 | end) 103 | end 104 | 105 | defp reconcile_aliases(mods, []), do: mods 106 | 107 | defp reconcile_aliases(mods, aliases) do 108 | mods 109 | |> remove_bare_aliases(aliases) 110 | |> remove_as_aliases(aliases) 111 | |> remove_multi_aliases(aliases) 112 | end 113 | 114 | defp remove_bare_aliases(mods, aliases) do 115 | bare_aliases = 116 | aliases 117 | |> Enum.filter(fn 118 | [{:__aliases__, _meta, _alias_info}] -> true 119 | _ -> false 120 | end) 121 | |> Enum.map(fn [{:__aliases__, _meta, alias_info}] -> alias_info end) 122 | 123 | filtered = 124 | mods 125 | |> Enum.filter(fn module_info -> !Enum.member?(bare_aliases, module_info) end) 126 | |> Enum.map(fn module_info -> 127 | matching_alias = 128 | Enum.find(bare_aliases, fn alias_info -> 129 | List.last(alias_info) == hd(module_info) 130 | end) 131 | 132 | if is_nil(matching_alias) do 133 | module_info 134 | else 135 | Enum.drop(matching_alias, -1) ++ module_info 136 | end 137 | end) 138 | 139 | filtered 140 | end 141 | 142 | defp remove_as_aliases(mods, aliases) do 143 | as_aliases = 144 | aliases 145 | |> Enum.filter(fn 146 | [{:__aliases__, _, _}, [as: {:__aliases__, _, _}]] -> true 147 | _ -> false 148 | end) 149 | |> Enum.map(fn [{_, _, full_info}, [as: {_, _, alias_info}]] -> {full_info, alias_info} end) 150 | 151 | filtered = 152 | mods 153 | |> Enum.reject(fn module_info -> 154 | Enum.any?(as_aliases, fn {full_name, _alias_name} -> 155 | module_info == full_name 156 | end) 157 | end) 158 | |> Enum.map(fn module_info -> 159 | matching_alias = 160 | Enum.find(as_aliases, fn {_, alias_name} -> 161 | alias_name == module_info 162 | end) 163 | 164 | if is_nil(matching_alias) do 165 | module_info 166 | else 167 | {new_name, _} = matching_alias 168 | new_name 169 | end 170 | end) 171 | 172 | filtered 173 | end 174 | 175 | defp remove_multi_aliases(mods, aliases) do 176 | multi_aliases = 177 | aliases 178 | |> Enum.filter(fn 179 | [{{:., _, [{:__aliases__, _, _}, :{}]}, _, _}] -> true 180 | _ -> false 181 | end) 182 | |> Enum.flat_map(fn [{{:., _, [{_, _, outside}, _]}, _, aliases}] -> 183 | Enum.map(aliases, fn {:__aliases__, _, suffix} -> outside ++ suffix end) 184 | end) 185 | 186 | filtered = 187 | mods 188 | |> Enum.reject(fn module_info -> 189 | Enum.any?(multi_aliases, fn alias_info -> 190 | module_info == Enum.drop(alias_info, -1) 191 | end) 192 | end) 193 | |> Enum.map(fn module_info -> 194 | matching_alias = 195 | Enum.find(multi_aliases, fn alias_info -> 196 | [List.last(alias_info)] == module_info 197 | end) 198 | 199 | if is_nil(matching_alias) do 200 | module_info 201 | else 202 | matching_alias 203 | end 204 | end) 205 | 206 | filtered 207 | end 208 | 209 | defp format_module(module_info) do 210 | Enum.join(module_info, ".") 211 | end 212 | 213 | @doc """ 214 | Takes a list of dependencies and returns a string that is a valid `dot` file. 215 | """ 216 | @spec create_gv_file(list) :: String.t() 217 | def create_gv_file(dependency_list) do 218 | body = Enum.map(dependency_list, fn {mod1, mod2} -> " \"#{mod1}\" -> \"#{mod2}\";" end) 219 | "digraph G {\n#{Enum.join(body, "\n")}\n}\n" 220 | end 221 | 222 | @doc """ 223 | This creates the graphviz file on disk, then runs the `dot` command to 224 | generate the graph as a PNG, and opens that PNG for you. 225 | """ 226 | @spec create_and_open_graph(String.t()) :: {Collectable.t(), exit_status :: non_neg_integer} 227 | def create_and_open_graph(gv_file) do 228 | gv_file_path = "./output.gv" 229 | graph_path = "./graph.png" 230 | File.write(gv_file_path, gv_file) 231 | System.cmd("dot", ["-Tpng", gv_file_path, "-o", graph_path]) 232 | System.cmd("open", [graph_path]) 233 | end 234 | end 235 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ModuleDependencyVisualizer.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :module_dependency_visualizer, 7 | version: "0.1.0", 8 | elixir: "~> 1.6-dev", 9 | start_permanent: Mix.env == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | # Run "mix help compile.app" to learn about applications. 15 | def application do 16 | [ 17 | extra_applications: [:logger] 18 | ] 19 | end 20 | 21 | # Run "mix help deps" to learn about dependencies. 22 | defp deps do 23 | [] 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /run.exs: -------------------------------------------------------------------------------- 1 | ModuleDependencyVisualizer.run(Path.wildcard("/Users/devoncestes/sandbox/benchee/lib/**/*.{ex, exs}")) 2 | -------------------------------------------------------------------------------- /test/module_dependency_visualizer_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ModuleDependencyVisualizerTest do 2 | use ExUnit.Case 3 | alias ModuleDependencyVisualizer, as: MDV 4 | 5 | describe "analyze/1 when is_binary" do 6 | test "analyzing a file without aliases produces the right dependencies" do 7 | file = """ 8 | defmodule Tester.One do 9 | def first(input) do 10 | String.length(input) 11 | List.first(input) 12 | end 13 | 14 | def second(input) do 15 | :lists.sort(input) 16 | end 17 | 18 | def third(input) do 19 | Tester.Other.first(input) 20 | end 21 | 22 | def fourth(input) do 23 | My.Long.Module.Chain.first(input) 24 | end 25 | end 26 | """ 27 | 28 | result = file |> MDV.analyze() |> Enum.sort() 29 | 30 | assert result == 31 | Enum.sort([ 32 | {"Tester.One", "String"}, 33 | {"Tester.One", "List"}, 34 | {"Tester.One", "lists"}, 35 | {"Tester.One", "Tester.Other"}, 36 | {"Tester.One", "My.Long.Module.Chain"} 37 | ]) 38 | end 39 | 40 | test "analyzing a file with aliases produces the right dependencies" do 41 | file = """ 42 | defmodule Tester.One do 43 | alias Tester.MyOther, as: Other 44 | alias My.Long.Module.Chain 45 | 46 | def first(input) do 47 | String.length(input) 48 | List.first(input) 49 | end 50 | 51 | def second(input) do 52 | :lists.sort(input) 53 | end 54 | 55 | def third(input) do 56 | Other.first(input) 57 | end 58 | 59 | def fourth(input) do 60 | Chain.first(input) 61 | end 62 | end 63 | 64 | defmodule Tester.Two do 65 | alias Tester.Four 66 | alias Tester.Multi.{One, Three} 67 | 68 | def first(input) do 69 | input 70 | |> One.first 71 | |> Three.first 72 | |> Four.first 73 | end 74 | end 75 | """ 76 | 77 | result = file |> MDV.analyze() |> Enum.sort() 78 | 79 | assert result == 80 | Enum.sort([ 81 | {"Tester.One", "String"}, 82 | {"Tester.One", "List"}, 83 | {"Tester.One", "lists"}, 84 | {"Tester.One", "Tester.MyOther"}, 85 | {"Tester.One", "My.Long.Module.Chain"}, 86 | {"Tester.Two", "Tester.Multi.One"}, 87 | {"Tester.Two", "Tester.Multi.Three"}, 88 | {"Tester.Two", "Tester.Four"} 89 | ]) 90 | end 91 | 92 | test "analyzing a file with use/import/require produces the right dependencies" do 93 | file = """ 94 | defmodule Tester.One do 95 | alias Tester.MyOther, as: Other 96 | alias My.Long 97 | 98 | def first(input) do 99 | String.length(input) 100 | List.first(input) 101 | end 102 | 103 | def second(input) do 104 | :lists.sort(input) 105 | end 106 | 107 | def third(input) do 108 | Other.first(input) 109 | end 110 | 111 | def fourth(input) do 112 | Long.Module.Chain.first(input) 113 | end 114 | end 115 | 116 | defmodule Tester.Two do 117 | alias Tester.{One, Three} 118 | import Tester.Five 119 | use Tester.Macro 120 | require Tester.Logger, as: Logger 121 | 122 | def first(input) do 123 | input |> One.third |> Tester.Logger.log 124 | Three.first(input) 125 | end 126 | end 127 | """ 128 | 129 | result = file |> MDV.analyze() |> Enum.sort() 130 | 131 | assert result == 132 | Enum.sort([ 133 | {"Tester.One", "String"}, 134 | {"Tester.One", "List"}, 135 | {"Tester.One", "lists"}, 136 | {"Tester.One", "Tester.MyOther"}, 137 | {"Tester.One", "My.Long.Module.Chain"}, 138 | {"Tester.Two", "Tester.One"}, 139 | {"Tester.Two", "Tester.Three"}, 140 | {"Tester.Two", "Tester.Five"}, 141 | {"Tester.Two", "Tester.Macro"}, 142 | {"Tester.Two", "Tester.Logger"} 143 | ]) 144 | end 145 | end 146 | 147 | describe "create_gv_file/1" do 148 | test "turns a dependency list into a properly formatted graphviz file" do 149 | dependency_list = [ 150 | {"Tester.One", "String"}, 151 | {"Tester.One", "lists"}, 152 | {"Tester.One", "Tester.MyOther"}, 153 | {"Tester.One", "My.Long.Module.Chain"}, 154 | {"Tester.Two", "Tester.One"} 155 | ] 156 | 157 | expected = """ 158 | digraph G { 159 | "Tester.One" -> "String"; 160 | "Tester.One" -> "lists"; 161 | "Tester.One" -> "Tester.MyOther"; 162 | "Tester.One" -> "My.Long.Module.Chain"; 163 | "Tester.Two" -> "Tester.One"; 164 | } 165 | """ 166 | 167 | assert MDV.create_gv_file(dependency_list) == expected 168 | end 169 | end 170 | end 171 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------