defmodule Credo.Check.Design.AliasUsage do
use Credo.Check,
id: "EX2001",
base_priority: :normal,
param_defaults: [
excluded_namespaces: ~w[File IO Inspect Kernel Macro Supervisor Task Version],
excluded_lastnames: ~w[Access Agent Application Atom Base Behaviour
Bitwise Code Date DateTime Dict Enum Exception
File Float GenEvent GenServer HashDict HashSet
Integer IO Kernel Keyword List Macro Map MapSet
Module NaiveDateTime Node OptionParser Path Port
Process Protocol Range Record Regex Registry Set
Stream String StringIO Supervisor System Task Time
Tuple URI Version],
if_called_more_often_than: 0,
if_nested_deeper_than: 0,
if_referenced: false,
only: nil
],
explanations: [
check: """
Functions from other modules should be used via an alias if the module's
namespace is not top-level.
While this is completely fine:
defmodule MyApp.Web.Search do
def twitter_mentions do
MyApp.External.TwitterAPI.search(...)
end
end
... you might want to refactor it to look like this:
defmodule MyApp.Web.Search do
alias MyApp.External.TwitterAPI
def twitter_mentions do
TwitterAPI.search(...)
end
end
The thinking behind this is that you can see the dependencies of your module
at a glance. So if you are attempting to build a medium to large project,
this can help you to get your boundaries/layers/contracts right.
As always: This is just a suggestion. Check the configuration options for
tweaking or disabling this check.
""",
params: [
excluded_namespaces: "List of namespaces to be excluded for this check.",
excluded_lastnames: "List of lastnames to be excluded for this check.",
if_nested_deeper_than: "Only raise an issue if a module is nested deeper than this.",
if_called_more_often_than:
"Only raise an issue if a module is called more often than this.",
if_referenced:
"Raise an issue if a module is referenced by name, e.g. as an argument in a function call.",
only: """
Regex or a list of regexes that specifies which modules to include for this check.
`excluded_namespaces` and `excluded_lastnames` take precedence over this parameter.
"""
]
]
alias Credo.Code.Name
@keywords [:alias]
@doc false
@impl true
def run(%SourceFile{} = source_file, params) do
issue_meta = IssueMeta.for(source_file, params)
excluded_namespaces = Params.get(params, :excluded_namespaces, __MODULE__)
excluded_lastnames = Params.get(params, :excluded_lastnames, __MODULE__)
if_nested_deeper_than = Params.get(params, :if_nested_deeper_than, __MODULE__)
if_called_more_often_than = Params.get(params, :if_called_more_often_than, __MODULE__)
only = Params.get(params, :only, __MODULE__)
if_referenced? = Params.get(params, :if_referenced, __MODULE__)
source_file
|> Credo.Code.prewalk(
&traverse(&1, &2, issue_meta, excluded_namespaces, excluded_lastnames, only, if_referenced?)
)
|> filter_issues_if_called_more_often_than(if_called_more_often_than)
|> filter_issues_if_nested_deeper_than(if_nested_deeper_than)
end
defp traverse(
{:defmodule, _, _} = ast,
issues,
issue_meta,
excluded_namespaces,
excluded_lastnames,
only,
if_referenced?
) do
aliases = Credo.Code.Module.aliases(ast)
mod_deps = Credo.Code.Module.modules(ast)
new_issues =
Credo.Code.prewalk(
ast,
&find_issues(
&1,
&2,
issue_meta,
excluded_namespaces,
excluded_lastnames,
only,
aliases,
mod_deps,
if_referenced?
)
)
{ast, issues ++ new_issues}
end
defp traverse(
ast,
issues,
_source_file,
_excluded_namespaces,
_excluded_lastnames,
_only,
_if_referenced?
) do
{ast, issues}
end
# Ignore module attributes
defp find_issues({:@, _, _}, issues, _, _, _, _, _, _, _) do
{nil, issues}
end
# Ignore multi alias call
defp find_issues(
{:., _, [{:__aliases__, _, _}, :{}]} = ast,
issues,
_,
_,
_,
_,
_,
_,
_
) do
{ast, issues}
end
# Ignore alias containing an `unquote` call
defp find_issues(
{:., _, [{:__aliases__, _, mod_list}, :unquote]} = ast,
issues,
_,
_,
_,
_,
_,
_,
_
)
when is_list(mod_list) do
{ast, issues}
end
defp find_issues(
{:., _, [{:__aliases__, meta, mod_list}, fun_atom]} = ast,
issues,
issue_meta,
excluded_namespaces,
excluded_lastnames,
only,
aliases,
mod_deps,
_if_referenced?
)
when is_list(mod_list) and is_atom(fun_atom) do
do_find_issues(
ast,
mod_list,
meta,
issues,
issue_meta,
excluded_namespaces,
excluded_lastnames,
only,
aliases,
mod_deps
)
end
defp find_issues(
{fun_atom, _, [{:__aliases__, meta, mod_list}]} = ast,
issues,
issue_meta,
excluded_namespaces,
excluded_lastnames,
only,
aliases,
mod_deps,
true
)
when is_list(mod_list) and is_atom(fun_atom) and fun_atom not in @keywords do
do_find_issues(
ast,
mod_list,
meta,
issues,
issue_meta,
excluded_namespaces,
excluded_lastnames,
only,
aliases,
mod_deps
)
end
defp find_issues(ast, issues, _, _, _, _, _, _, _) do
{ast, issues}
end
defp do_find_issues(
ast,
mod_list,
meta,
issues,
issue_meta,
excluded_namespaces,
excluded_lastnames,
only,
aliases,
mod_deps
) do
cond do
Enum.count(mod_list) <= 1 || Enum.any?(mod_list, &tuple?/1) ->
{ast, issues}
Enum.any?(mod_list, &unquote?/1) ->
{ast, issues}
excluded_lastname_or_namespace?(
mod_list,
excluded_namespaces,
excluded_lastnames
) ->
{ast, issues}
excluded_with_only?(mod_list, only) ->
{ast, issues}
conflicting_with_aliases?(mod_list, aliases) ->
{ast, issues}
conflicting_with_other_modules?(mod_list, mod_deps) ->
{ast, issues}
true ->
trigger = Credo.Code.Name.full(mod_list)
{ast, issues ++ [issue_for(issue_meta, meta[:line], trigger)]}
end
end
defp unquote?({:unquote, _, arguments}) when is_list(arguments), do: true
defp unquote?(_), do: false
defp excluded_lastname_or_namespace?(
mod_list,
excluded_namespaces,
excluded_lastnames
) do
first_name = Credo.Code.Name.first(mod_list)
last_name = Credo.Code.Name.last(mod_list)
Enum.member?(excluded_namespaces, first_name) || Enum.member?(excluded_lastnames, last_name)
end
defp excluded_with_only?(_mod_list, nil), do: false
defp excluded_with_only?(mod_list, only) when is_list(only) do
Enum.any?(only, &excluded_with_only?(mod_list, &1))
end
defp excluded_with_only?(mod_list, %Regex{} = only) do
name = Credo.Code.Name.full(mod_list)
!String.match?(name, only)
end
# Returns true if mod_list and alias_name would result in the same alias
# since they share the same last name.
defp conflicting_with_aliases?(mod_list, aliases) do
last_name = Credo.Code.Name.last(mod_list)
Enum.find(aliases, &conflicting_alias?(&1, mod_list, last_name))
end
defp conflicting_alias?(alias_name, mod_list, last_name) do
full_name = Credo.Code.Name.full(mod_list)
alias_last_name = Credo.Code.Name.last(alias_name)
full_name != alias_name && alias_last_name == last_name
end
# Returns true if mod_list and any dependent module would result in the same alias
# since they share the same last name.
defp conflicting_with_other_modules?(mod_list, mod_deps) do
full_name = Credo.Code.Name.full(mod_list)
last_name = Credo.Code.Name.last(mod_list)
(mod_deps -- [full_name])
|> Enum.filter(&(Credo.Code.Name.parts_count(&1) > 1))
|> Enum.map(&Credo.Code.Name.last/1)
|> Enum.any?(&(&1 == last_name))
end
defp tuple?(t) when is_tuple(t), do: true
defp tuple?(_), do: false
defp filter_issues_if_called_more_often_than(issues, 0) do
issues
end
defp filter_issues_if_called_more_often_than(issues, count) do
issues
|> Enum.reduce(%{}, fn issue, memo ->
list = memo[issue.trigger] || []
Map.put(memo, issue.trigger, [issue | list])
end)
|> Enum.filter(fn {_trigger, issues} ->
length(issues) > count
end)
|> Enum.flat_map(fn {_trigger, issues} ->
issues
end)
end
defp filter_issues_if_nested_deeper_than(issues, count) do
Enum.filter(issues, fn issue ->
Name.parts_count(issue.trigger) > count
end)
end
defp issue_for(issue_meta, line_no, trigger) do
format_issue(
issue_meta,
message: "Nested modules could be aliased at the top of the invoking module.",
trigger: trigger,
line_no: line_no
)
end
end