Packages
credo
0.4.13
1.7.19
1.7.18
1.7.17
1.7.16
1.7.15
1.7.14
1.7.13
1.7.12
1.7.11
1.7.10
1.7.9
1.7.8
1.7.7
1.7.7-rc.0
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.2-rc.4
1.7.2-rc.3
1.7.2-rc.2
1.7.2-rc.1
1.7.2-rc.0
1.7.1
1.7.0
1.7.0-rc.2
1.7.0-rc.1
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.6.2
1.6.1
1.6.0
1.6.0-rc.1
1.6.0-rc.0
1.5.6
1.5.5
1.5.4
1.5.3
1.5.2
1.5.1
1.5.0
1.5.0-rc.5
1.5.0-rc.4
1.5.0-rc.3
1.5.0-rc.2
1.5.0-rc.1
1.4.1
1.4.0
1.4.0-rc.2
1.4.0-rc.1
1.3.2
1.3.1
1.3.0
1.3.0-rc3
1.3.0-rc2
1.3.0-rc1
1.2.3
1.2.2
1.2.1
1.2.0
1.2.0-rc4
1.2.0-rc3
1.2.0-rc2
1.2.0-rc1
1.1.5
1.1.4
1.1.3
1.1.2
1.1.1
1.1.0
1.1.0-rc3
1.1.0-rc2
1.1.0-rc1
1.0.5
1.0.4
1.0.3
1.0.2
1.0.1
1.0.1-rc1
1.0.0
1.0.0-rc1
0.10.2
0.10.1
0.10.0
0.9.3
0.9.2
0.9.1
0.9.0
0.9.0-rc8
0.9.0-rc7
0.9.0-rc6
0.9.0-rc5
0.9.0-rc4
0.9.0-rc3
0.9.0-rc2
0.9.0-rc1
0.8.10
0.8.9
0.8.8
0.8.7
0.8.6
0.8.5
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.8.0-rc7
0.8.0-rc6
0.8.0-rc5
0.8.0-rc4
0.8.0-rc3
0.8.0-rc2
0.8.0-rc1
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.1
0.6.0
0.6.0-rc2
0.6.0-rc1
0.5.3
0.5.2
0.5.1
0.5.0
0.4.14
0.4.13
0.4.12
0.4.11
0.4.10
0.4.10-dev
0.4.9
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.4.0-beta5
0.4.0-beta4
0.4.0-beta3
0.4.0-beta2
0.4.0-beta1
0.3.13
0.3.12
0.3.11
0.3.10
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.3.0-dev2
0.3.0-dev
0.2.6
0.2.5
0.2.4
0.2.3
0.2.2
0.2.1
0.2.0
0.1.10
0.1.9
0.1.8
0.1.7
0.1.6
0.1.5
0.1.4
0.1.3
0.1.2
0.1.1
0.1.0
0.0.1-dev
A static code analysis tool with a focus on code consistency and teaching.
Current section
Files
Jump to
Current section
Files
lib/credo/check/design/duplicated_code.ex
defmodule Credo.Check.Design.DuplicatedCode do
@moduledoc """
Code should not be copy-pasted in a codebase when there is room to abstract
the copied functionality in a meaningful way.
That said, you should by no means "ABSTRACT ALL THE THINGS!".
Sometimes it can serve a purpose to have code be explicit in two places, even
if it means the snippets are nearly identical. A good example for this are
Database Adapters in a project like Ecto, where you might have nearly
identical functions for things like `order_by` or `limit` in both the
Postgres and MySQL adapters.
In this case, introducing an `AbstractAdapter` just to avoid code duplication
might cause more trouble down the line than having a bit of duplicated code.
Like all `Software Design` issues, this is just advice and might not be
applicable to your project/situation.
"""
@explanation [
check: @moduledoc,
params: [
mass_threshold: "The minimum mass which a part of code has to have to qualify for this check.",
nodes_threshold: "The number of nodes that need to be found to raise an issue."
]
]
@default_params [
mass_threshold: 40,
nodes_threshold: 2,
excluded_macros: []
]
alias Credo.SourceFile
alias Credo.Issue
alias Credo.Check.CodeHelper
use Credo.Check, run_on_all: true, base_priority: :higher
def run(source_files, params \\ []) when is_list(source_files) do
mass_threshold = params |> Params.get(:mass_threshold, @default_params)
nodes_threshold = params |> Params.get(:nodes_threshold, @default_params)
source_files
|> duplicate_nodes(mass_threshold)
|> append_issues_via_issue_service(source_files, nodes_threshold, params)
:ok
end
defp append_issues_via_issue_service(found_hashes, source_files, nodes_threshold, params) when is_map(found_hashes) do
Enum.each(found_hashes, fn({_hash, nodes}) ->
filenames = nodes |> Enum.map(&(&1.filename))
Enum.each(source_files, fn(source_file) ->
if Enum.member?(filenames, source_file.filename) do
this_node = Enum.find(nodes, &(&1.filename == source_file.filename))
other_nodes = List.delete(nodes, this_node)
issue_meta = IssueMeta.for(source_file, params)
issue = issue_for(issue_meta, this_node, other_nodes, nodes_threshold, params)
if issue do
Credo.Service.SourceFileIssues.append(source_file, issue)
end
end
end)
end)
end
defp duplicate_nodes(source_files, mass_threshold) do
source_files
|> Enum.reduce(%{}, fn(source_file, acc) ->
calculate_hashes(source_file.ast, acc, source_file.filename, mass_threshold)
end)
|> prune_hashes
|> add_masses
end
def add_masses(found_hashes) do
found_hashes
|> Enum.map(&add_mass_to_subnode/1)
|> Enum.into(%{})
end
defp add_mass_to_subnode({hash, node_items}) do
node_items =
node_items
|> Enum.map(fn(node_item) ->
%{node_item | mass: mass(node_item.node)}
end)
{hash, node_items}
end
@doc """
Takes a map of hashes to nodes and prunes those nodes that are just
subnodes of others in the same set.
Returns the resulting map.
"""
def prune_hashes(given_hashes, mass_threshold \\ @default_params[:mass_threshold]) do
# remove entries containing a single node
hashes_with_multiple_nodes =
given_hashes
|> Enum.filter(fn {_hash, node_items} -> Enum.count(node_items) > 1 end)
|> Enum.into(%{})
hashes_to_prune =
Enum.flat_map(hashes_with_multiple_nodes, &collect_subhashes(&1, mass_threshold))
delete_keys(hashes_to_prune, hashes_with_multiple_nodes)
end
defp delete_keys([], acc), do: acc
defp delete_keys([head | tail], acc) do
delete_keys(tail, Map.delete(acc, head))
end
defp collect_subhashes({_hash, node_items}, mass_threshold) do
%{node: first_node, filename: filename} = Enum.at(node_items, 0)
my_hash = first_node |> CodeHelper.remove_metadata |> to_hash
subhashes =
first_node
|> calculate_hashes(%{}, filename, mass_threshold)
|> Map.keys
|> List.delete(my_hash) # don't count self
subhashes
end
@doc """
Calculates hash values for all sub nodes in a given +ast+.
Returns a map with the hashes as keys and the nodes as values.
"""
def calculate_hashes(ast, existing_hashes \\ %{}, filename \\ "foo.ex", mass_threshold \\ @default_params[:mass_threshold]) when is_map(existing_hashes) do
Credo.Code.prewalk(ast,
&collect_hashes(&1, &2, filename, mass_threshold), existing_hashes)
end
defp collect_hashes(ast, acc, filename, mass_threshold) do
if mass(ast) < mass_threshold do
{ast, acc}
else
hash = ast |> CodeHelper.remove_metadata |> to_hash
node_item = %{node: ast, filename: filename, mass: nil}
node_items = Map.get(acc, hash, [])
acc = Map.put(acc, hash, node_items ++ [node_item])
{ast, acc}
end
end
@doc """
Returns a hash-value for a given +ast+.
"""
def to_hash(ast) do
string =
ast
|> Inspect.Algebra.to_doc(%Inspect.Opts{})
|> Inspect.Algebra.format(80)
|> Enum.join("")
:sha256
|> :crypto.hash(string)
|> Base.encode16
end
@doc """
Returns the mass (count of instructions) for an AST.
"""
def mass(ast) do
Credo.Code.prewalk(ast, &calc_mass/2, 0)
end
defp calc_mass(ast, acc) when is_tuple(ast) do
{ast, acc + 1}
end
defp calc_mass(ast, acc) do
{ast, acc}
end
defp issue_for(issue_meta, this_node, other_nodes, nodes_threshold, params) do
if Enum.count(other_nodes) >= nodes_threshold - 1 do
filenames =
other_nodes
|> Enum.map(fn(other_node) -> "#{other_node.filename}:#{line_no_for(other_node.node)}" end)
|> Enum.join(", ")
node_mass = this_node.mass
line_no = line_no_for(this_node.node)
excluded_macros = params[:excluded_macros] || []
if create_issue?(this_node.node, excluded_macros) do
format_issue issue_meta,
message: "Duplicate code found in #{filenames} (mass: #{node_mass}).",
line_no: line_no,
severity: Severity.compute(1 + Enum.count(other_nodes), 1)
end
end
end
# ignore similar module attributes, no matter how complex
def create_issue?({:@, _, _}, _), do: false
def create_issue?([do: {atom, _, arguments}], excluded_macros) when is_atom(atom) and is_list(arguments) do
!Enum.member?(excluded_macros, atom)
end
def create_issue?({atom, _, arguments}, excluded_macros) when is_atom(atom) and is_list(arguments) do
!Enum.member?(excluded_macros, atom)
end
def create_issue?(_ast, _), do: true
# TODO: Put in AST helper
def line_no_for({:__block__, _meta, arguments}) do
arguments |> line_no_for()
end
def line_no_for({:do, arguments}) do
arguments |> line_no_for()
end
def line_no_for({atom, meta, _}) when is_atom(atom) do
meta[:line]
end
def line_no_for(list) when is_list(list) do
list |> Enum.find_value(&line_no_for/1)
end
def line_no_for(nil), do: nil
def line_no_for(block) do
block
|> CodeHelper.do_block_for!
|> line_no_for
end
end