defmodule Recode.CLIFormatter do @moduledoc false use GenServer import Recode.Formatter alias IO.ANSI alias Rewrite.Source @theme %{ gainsboro: ANSI.color(4, 4, 4), orange: ANSI.color(5, 3, 0), aquamarine: ANSI.color(2, 5, 4), gray: ANSI.color(59), info: :gainsboro, file: :green, issue: :cyan, warn: :orange, debug: :cyan, ok: :green, updated: :green, correctable: :aquamarine, error: :red, del: :red, del_background: :red_background, ins: :green, ins_background: :green_background, skip: :yellow, separator: :gray, blank: " " } @diff_format [ colors: [ del: [text: :del, space: :del_background], ins: [text: :ins, space: :ins_background], skip: [text: :skip], separator: [text: :separator] ] ] @default_config [debug: false, verbose: false] def init(config) do coloring = case Keyword.get(config, :color, true) do false -> [emit: false] true -> [emit: true, theme: @theme] theme -> [emit: true, theme: theme] end config = config |> Keyword.take([:debug, :verbose]) |> merge_into(@default_config) |> Keyword.merge(coloring) |> Keyword.put(:colorizer, Escape.colorizer(coloring)) {:ok, config} end defp merge_into(keywords1, keywords2), do: Keyword.merge(keywords2, keywords1) def handle_cast({:prepared, %Rewrite{} = project, time}, config) when is_integer(time) do case Enum.count(project.sources) do 0 -> :ok 1 -> Escape.puts([:info, "Read 1 file"], config) count -> Escape.puts([:info, "Read #{count} files in #{format_time(time)}s"], config) end {:noreply, config} end def handle_cast({:finished, %Rewrite{} = project, time}, config) when is_integer(time) do unless Enum.empty?(project) do Escape.puts([:info, "Finished in #{format_time(time)}s."], config) end {:noreply, config} end def handle_cast({:task_started, %Source{} = source, task}, config) when is_atom(task) do if config[:debug] do Escape.puts([:debug, "Start #{task} with #{source.path}."], config) end {:noreply, config} end def handle_cast({:task_finished, %Source{} = source, task, time}, config) when is_atom(task) do if config[:debug] do Escape.puts([:debug, "Finished #{task} with #{source.path} [#{time}μs]."], config) else cond do issue?(source, task) -> Escape.write([:warn, "!"], config) changed?(source, task) -> Escape.write([:updated, "!"], config) true -> Escape.write([:ok, "."], config) end end config = Keyword.update(config, :times, [{task, source.path, time}], fn times -> [{task, source.path, time} | times] end) {:noreply, config} end def handle_cast({:tasks_finished, %Rewrite{} = project, time}, config) do unless Enum.empty?(project) do Escape.puts("") stats = format_results(project, config) :ok = format_tasks_stats(config, time) :ok = format_slowest_tasks(config[:slowest_tasks], config) :ok = format_stats(project, stats, config) :ok = format_ok(stats, config) end {:noreply, config} end defp format_tasks_stats(config, time) do executions = length(Keyword.get(config, :times, [])) Escape.puts([:info, "Executed #{executions} tasks in #{format_time(time)}s."], config) end defp format_results(project, config) do verbose = Keyword.fetch!(config, :verbose) stats = %{extname_count: %{}, issues: 0, updated: 0, created: 0, moved: 0} project |> Rewrite.sources() |> Enum.reduce(stats, fn source, stats -> opts = [ issues?: Source.has_issues?(source, :all), code_updated?: Source.updated?(source, :content), path_updated?: Source.updated?(source, :path), created?: Source.from?(source, :string) ] format_result(source, verbose, opts, config) stats |> stats_count(:issues, opts[:issues?], Enum.count(source.issues)) |> stats_count(:updated, opts[:code_updated?]) |> stats_count(:moved, opts[:path_updated?]) |> stats_count(:created, opts[:created?]) |> Map.update!(:extname_count, fn extname_count -> extname = Path.extname(source.path) Map.update(extname_count, extname, 1, fn count -> count + 1 end) end) end) end defp format_stats(project, stats, config) do filte_stats = stats |> Map.get(:extname_count) |> Enum.map_join(", ", fn {extname, count} -> "#{extname}: #{count}" end) Escape.puts([:info, "Files: #{Enum.count(project)} ", "(#{filte_stats})"], config) _stats = stats |> format_stat(:created, :info, ["Created # file", "Created # files"], config) |> format_stat(:moved, :info, ["Moved # file", "Moved # files"], config) |> format_stat(:updated, :info, ["Updated # file", "Updated # files"], config) :ok end defp format_ok(%{issues: issues}, config) do output = case issues do 0 -> [:ok, :reverse, :blank, "Everything ok", :blank, :reverse_off] 1 -> [:warn, :reverse, :blank, "Found 1 issue", :blank, :reverse_off] count -> [:warn, :reverse, :blank, "Found #{count} issues", :blank, :reverse_off] end Escape.puts(output, config) end defp format_stat(stats, key, kind, templates, config) do output = case Map.fetch!(stats, key) do 0 -> [] 1 -> [kind, templates |> Enum.at(0) |> String.replace("#", "1"), "\n"] count -> [kind, templates |> Enum.at(1) |> String.replace("#", "#{count}"), "\n"] end Escape.write(output, config) stats end defp stats_count(stats, key, count?, add \\ 1) defp stats_count(stats, key, true, add) do Map.update!(stats, key, fn value -> value + add end) end defp stats_count(stats, _key, false, _add), do: stats defp format_result(source, verbose, opts, config) do issues? = opts[:issues?] code_updated? = opts[:code_updated?] and verbose path_updated? = opts[:path_updated?] and verbose created? = opts[:created?] and verbose updated? = code_updated? or path_updated? [] |> format_file(source, issues? or updated? or created?) |> format_created(source, created?) |> format_updates(source, updated?) |> format_path_update(source, path_updated?) |> format_code_update(source, code_updated?, config) |> format_issues(source, issues?, verbose) |> then(fn [] -> [] content -> Enum.concat(content, ["\n"]) end) |> Escape.write(config) end defp format_file(output, _source, false), do: output defp format_file(output, source, true) do Enum.concat(output, [ :file, :reverse, :blank, "File: #{source.path || "no file"}", :blank, :reverse_off, "\n" ]) end defp format_created(output, _source, false), do: output defp format_created(output, source, true) do owner = case Source.owner(source) do Rewrite -> "" module -> ", created by #{inspect(module)}" end Enum.concat(output, [:info, "New file", "#{owner}\n"]) end defp format_updates(output, _source, false), do: output defp format_updates(output, source, true) do Enum.concat(output, [:info, "Updates: #{Source.version(source) - 1}\n"]) end defp format_path_update(output, _source, false), do: output defp format_path_update(output, source, true) do Enum.concat([ output, changed_by(source), ["Moved from: #{Source.get(source, :path, 1)}\n"] ]) end defp format_code_update(output, _source, false, _config), do: output defp format_code_update(output, source, true, config) do Enum.concat([ output, changed_by(source), [ANSI.reset()], [ source |> Source.diff( format: @diff_format, colorizer: config[:colorizer] ) |> IO.iodata_to_binary() ] ]) end defp format_issues(output, _source, false, _verbose), do: output defp format_issues(output, source, true, verbose) do actual = Source.version(source) issues = source |> Map.get(:issues) |> Enum.sort(&sort_issues/2) |> Enum.flat_map(fn {version, issue} -> format_issue(issue, version, actual, verbose) end) Enum.concat(output, issues) end defp sort_issues({_version1, issue1}, {_version2, issue2}) do line1 = Map.get(issue1, :line, 0) line2 = Map.get(issue2, :line, 0) cond do line1 == line2 -> column1 = Map.get(issue1, :column, 0) column2 = Map.get(issue2, :column, 0) column1 <= column2 line1 <= line2 -> true true -> false end end defp format_issue( %{reporter: Recode.Runner, meta: meta, message: message}, _version, _actual, true ) do [:error, "Execution of the #{inspect(meta[:task])} task failed with error:\n#{message}\n"] end defp format_issue(%{reporter: Recode.Runner, meta: meta}, _version, _actual, false) do [:error, "Execution of the #{inspect(meta[:task])} task failed.\n"] end defp format_issue(issue, version, actual, _verbose) do warn = case version != actual do true -> [:warn, "Version #{version}/#{actual} "] false -> [] end message = [ :issue, "[#{module(issue.reporter)} #{pos(issue)}] ", :info, "#{issue.message}\n" ] Enum.concat(warn, message) end defp format_slowest_tasks(nil, _config), do: :ok defp format_slowest_tasks(amount, config) do slowest_tasks = config |> Keyword.get(:times, []) |> Enum.reduce(%{}, fn {task, _path, time}, acc -> Map.update(acc, task, {1, time}, fn {calls, duration} -> {calls + 1, duration + time} end) end) |> Enum.into([], fn {task, {calls, time}} -> {task, calls, round(time / calls)} end) |> Enum.sort_by(&elem(&1, 2), :desc) |> Enum.take(amount) |> Enum.with_index(1) |> Enum.map(fn {{task, calls, time}, index} -> """ #{index} - \ task: #{inspect(task)}, \ calls: #{calls}, \ avg: #{format_time(time, :millisecond)}ms """ end) Escape.puts([:info, "\nSlowest tasks:\n", slowest_tasks], config) end defp changed_by(%Source{history: history}) do by = Enum.map(history, fn {_key, by, _value} -> module(by) end) [:info, ~s|Changed by: #{Enum.join(by, ", ")}\n|] end defp pos(issue) do line = Map.get(issue, :line) || "-" column = Map.get(issue, :column) || "-" "#{line}/#{column}" end defp changed?(%Source{history: [{:content, reporter, _content} | _]}, reporter), do: true defp changed?(_source, _reporter), do: false defp issue?(%Source{issues: [{_version, %{reporter: reporter}} | _]}, reporter), do: true defp issue?(_source, _reporter), do: false defp module(alias), do: alias |> split() |> List.last() defp split(module) when is_atom(module), do: module |> to_string() |> split() defp split("Elixir." <> name), do: String.split(name, ".") defp split(name) when is_binary(name), do: String.split(name, ".") end