defmodule Archeometer.Explore.Project do
@moduledoc """
Utilities for obtaining higher level information about a project, mostly
through Mix and Application.
"""
require Logger
@doc """
Get the (underscored) module name of the current project`mix.exs` file. Every
directory with one is considered a project by Mix.
"""
def get!() do
Mix.Project.get!()
|> Macro.to_string()
|> String.split(".")
|> hd()
|> Macro.underscore()
end
@doc """
Get all the locally defined modules.
"""
def local_modules(extra_deps \\ []) do
local_modules_by_app(extra_deps)
|> Map.values()
|> List.flatten()
end
@doc """
Obtain all the locally defined modules by application.
"""
def local_modules_by_app(extra_deps \\ []) do
apps = apps() ++ extra_deps
Enum.each(apps, fn app ->
case Application.ensure_loaded(app) do
{:error, reason} ->
Logger.warn(
"#{app} failed to load with #{inspect(reason)}; it will be ignored in further analysis"
)
ok ->
ok
end
end)
Map.new(apps, fn app -> {app, Application.spec(app, :modules)} end)
end
@doc """
Obtain the list of local app in the current project. This is the value of
`:app` in regular projects and all the children apps in umbrella projects.
"""
def apps() do
case Mix.Project.config()[:app] do
nil -> umbrella_apps()
app -> [app]
end
end
@doc """
Given a file path, try to guess its application. If the project is not
umbrella, just return the project app. Else try to parse the path to guess the
application.
"""
def guess_app_from_path(file_path) do
all_children = Mix.Project.deps_paths()
default =
if Mix.Project.umbrella?(),
do: {:error, :failed_to_guess},
else: {:ok, Mix.Project.config()[:app]}
Enum.find_value(
all_children,
default,
fn {dep, dep_path} ->
if String.contains?(
file_path,
Path.relative_to_cwd(dep_path) <> "\/"
),
do: {:ok, dep}
end
)
end
@doc """
Obtain all the apps defined as umbrella children.
"""
def umbrella_apps() do
all_children = Mix.Dep.Loader.children()
for %Mix.Dep{scm: Mix.SCM.Path, app: app, opts: opts} <- all_children,
opts[:from_umbrella] do
app
end
end
def list_behaviours(modules) do
Enum.reduce(modules, [], fn module, acc ->
attributes = module.__info__(:attributes)
if Keyword.has_key?(attributes, :behaviour) do
Enum.map(
Keyword.get_values(attributes, :behaviour) |> List.flatten(),
&%{
module: Macro.to_string(module),
name: Macro.to_string(&1)
}
) ++ acc
else
acc
end
end)
end
def list_modules_with_struct(modules) do
Enum.reduce(modules, [], fn module, acc ->
functions = module.__info__(:functions)
if Keyword.has_key?(functions, :__struct__) do
[Macro.to_string(module) | acc]
else
acc
end
end)
end
@doc """
Returns the project dependencies that matches the filters.
## Parameters
- filters: List of wildcard style strings to use as filters.
"""
def filter_deps([]), do: false
def filter_deps(filters) when is_list(filters) do
deps = Mix.Project.deps_paths()
load_and_cache = Mix.Dep.load_and_cache()
Enum.flat_map(
filters,
fn filter ->
Enum.filter(deps, &dep_matches?(&1, filter, load_and_cache))
end
)
|> Enum.uniq()
end
defp dep_matches?({app, _path}, filter, load_and_cache) do
matches_filter = {Wild.match?(Atom.to_string(app), filter), mix_managed?(app, load_and_cache)}
case matches_filter do
{true, true} ->
true
{true, false} ->
Logger.warn("Dependency #{app} is not Mix managed")
false
{_wild, _mix} ->
false
end
end
defp mix_managed?(dep, load_and_cache) do
dep_struct = Enum.find(load_and_cache, false, &(&1.app == dep))
if dep_struct do
Mix.Dep.mix?(dep_struct)
else
false
end
end
def get_curr_app_deps(_module) do
Mix.Project.config()[:deps]
|> Enum.map(&elem(&1, 0))
end
def in_app(app, fun) do
if app == Mix.Project.config()[:app] do
{:ok, fun.()}
else
with {:ok, app_path} <- get_dep_path(app) do
{:ok, Mix.Project.in_project(app, app_path, [], fun)}
end
end
end
def get_dep_path(dep) do
all_children = Mix.Dep.Loader.children()
matching_deps =
for %Mix.Dep{app: ^dep, opts: opts} <- all_children do
Keyword.get(opts, :dest)
end ++
for {^dep, path} <- Mix.Project.deps_paths() do
path
end
case matching_deps do
[] -> {:error, :not_found}
[path | _] -> {:ok, path}
end
end
def get_app_deps(app) do
if app == Mix.Project.config()[:app] do
{:ok, get_curr_app_deps(nil)}
else
in_app(app, &get_curr_app_deps/1)
end
end
def test_paths([]) do
Mix.Project.umbrella?()
|> prj_test_paths()
|> Enum.map(&Path.relative_to_cwd/1)
end
def test_paths(deps_filter) do
deps_to_include =
deps_filter
|> filter_deps()
|> Keyword.keys()
Mix.Dep.load_and_cache()
|> Enum.filter(&(&1.app in deps_to_include))
|> Enum.flat_map(&dep_test_paths/1)
|> Enum.concat(prj_test_paths(Mix.Project.umbrella?()))
|> Enum.map(&Path.relative_to_cwd/1)
end
defp dep_test_paths(dep) do
Mix.Dep.in_dependency(dep, fn _ ->
prj_test_paths(Mix.Project.umbrella?())
end)
end
defp prj_test_paths(umbrella?)
defp prj_test_paths(true) do
Mix.Dep.Umbrella.loaded()
|> Enum.flat_map(&dep_test_paths/1)
end
defp prj_test_paths(false) do
Mix.Project.config()
|> Keyword.get(:test_paths, ["test"])
|> Enum.map(&Path.expand/1)
end
def tag_external_apps(apps) do
Enum.map(apps, &%{app: &1, is_external: external_app?(&1)})
end
defp external_app?(app) do
case Mix.Project.apps_paths() do
nil ->
app != Mix.Project.config()[:app]
apps ->
app not in Map.keys(apps)
end
end
end