Current section
Files
Jump to
Current section
Files
lib/mix/tasks/ex_dna.explain.ex
defmodule Mix.Tasks.ExDna.Explain do
@shortdoc "Show detailed analysis for a specific clone"
@moduledoc """
Deep-dive into a specific clone, showing the anti-unification result,
the common structure, the divergence points (holes), and the suggested
refactoring.
$ mix ex_dna.explain 1
$ mix ex_dna.explain 1 --min-mass 10
The clone number comes from `mix ex_dna` output.
"""
use Mix.Task
alias ExDNA.AST.{AntiUnifier, Normalizer}
@impl Mix.Task
def run(argv) do
{opts, args, _} =
OptionParser.parse(argv,
strict: [
min_mass: :integer,
min_similarity: :float,
literal_mode: :string,
normalize_pipes: :boolean,
ignore: :keep
],
aliases: [m: :min_mass, s: :min_similarity, i: :ignore]
)
clone_index =
case args do
[n | _] -> String.to_integer(n)
_ -> 1
end
literal_mode =
case Keyword.get(opts, :literal_mode, "keep") do
"abstract" -> :abstract
_ -> :keep
end
config_opts =
[
reporters: [],
literal_mode: literal_mode,
normalize_pipes: Keyword.get(opts, :normalize_pipes, false),
ignore: Keyword.get_values(opts, :ignore)
]
|> maybe_put(:min_mass, Keyword.get(opts, :min_mass))
|> maybe_put(:min_similarity, Keyword.get(opts, :min_similarity))
report = ExDNA.analyze(config_opts)
case Enum.at(report.clones, clone_index - 1) do
nil ->
IO.puts([
"\n",
IO.ANSI.red(),
"Clone ##{clone_index} not found. ",
IO.ANSI.reset(),
"Found #{report.stats.total_clones} clones total.\n"
])
clone ->
explain_clone(clone, clone_index)
end
end
defp explain_clone(clone, index) do
IO.puts([
"\n",
IO.ANSI.yellow(),
"═══ Clone ##{index} — Detailed Analysis ",
String.duplicate("═", 40),
IO.ANSI.reset(),
"\n"
])
IO.puts([IO.ANSI.cyan(), "Type: ", IO.ANSI.reset(), format_type(clone.type)])
IO.puts([IO.ANSI.cyan(), "Mass: ", IO.ANSI.reset(), "#{clone.mass} AST nodes"])
IO.puts([
IO.ANSI.cyan(),
"Locations: ",
IO.ANSI.reset(),
"#{length(clone.fragments)} occurrences\n"
])
Enum.each(clone.fragments, fn frag ->
IO.puts([" • ", IO.ANSI.faint(), "#{frag.file}:#{frag.line}", IO.ANSI.reset()])
end)
if length(clone.fragments) >= 2 do
[frag_a, frag_b | _] = clone.fragments
ast_a = Normalizer.strip_metadata(frag_a.ast)
ast_b = Normalizer.strip_metadata(frag_b.ast)
{pattern, holes} = AntiUnifier.anti_unify(ast_a, ast_b)
IO.puts([
"\n",
IO.ANSI.yellow(),
"─── Common Structure ",
String.duplicate("─", 42),
IO.ANSI.reset(),
"\n"
])
pattern |> Macro.to_string() |> print_code()
if holes != [] do
IO.puts([
"\n",
IO.ANSI.yellow(),
"─── Divergence Points (#{length(holes)} holes) ",
String.duplicate("─", 30),
IO.ANSI.reset(),
"\n"
])
Enum.each(holes, fn hole ->
[val_a, val_b] = hole.values
IO.puts([
" ",
IO.ANSI.magenta(),
"#{hole.var}",
IO.ANSI.reset()
])
IO.puts([
" fragment A: ",
IO.ANSI.faint(),
Macro.to_string(val_a),
IO.ANSI.reset()
])
IO.puts([
" fragment B: ",
IO.ANSI.faint(),
Macro.to_string(val_b),
IO.ANSI.reset(),
"\n"
])
end)
end
if clone.suggestion do
IO.puts([
IO.ANSI.yellow(),
"─── Suggested Refactoring ",
String.duplicate("─", 37),
IO.ANSI.reset(),
"\n"
])
print_suggestion(clone.suggestion)
end
end
IO.puts("")
end
defp print_suggestion(%{kind: :extract_function} = s) do
params = Enum.join(s.params, ", ")
IO.puts([IO.ANSI.green(), " defp #{s.name}(#{params}) do", IO.ANSI.reset()])
s.body
|> String.split("\n")
|> Enum.each(fn line ->
IO.puts([IO.ANSI.green(), " #{line}", IO.ANSI.reset()])
end)
IO.puts([IO.ANSI.green(), " end", IO.ANSI.reset(), "\n"])
IO.puts([IO.ANSI.cyan(), " Call sites:", IO.ANSI.reset(), "\n"])
Enum.each(s.call_sites, fn site ->
IO.puts([
" ",
IO.ANSI.faint(),
"#{site.file}:#{site.line} → #{site.call}",
IO.ANSI.reset()
])
end)
end
defp print_code(code) do
code
|> String.split("\n")
|> Enum.each(fn line ->
IO.puts([" ", IO.ANSI.faint(), line, IO.ANSI.reset()])
end)
end
defp format_type(:type_i), do: "exact (Type I)"
defp format_type(:type_ii), do: "renamed (Type II)"
defp format_type(:type_iii), do: "near-miss (Type III)"
defp maybe_put(opts, _key, nil), do: opts
defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value)
end