defmodule Mix.Tasks.X.Format do @shortdoc "Formats the given files/patterns" @moduledoc """ Formatter task uses settings from `.formatter.exs` by default. All project files can be formatted with: mix x.format Also, formatter task can be used to format a specific file: mix x.format path/to/file.ex """ use Mix.Task # https://github.com/elixir-lang/elixir/blob/master/lib/mix/lib/mix/tasks/format.ex @switches [ dot_formatter: :string ] @impl true def run(args) do {opts, args} = OptionParser.parse!(args, strict: @switches) {dot_formatter, formatter_opts} = eval_dot_formatter(opts) args |> expand_args(dot_formatter, {formatter_opts, []}) |> Task.async_stream(&format_file(&1), ordered: false, timeout: 30000) |> Enum.reduce([], &collect_status/2) |> check!() end defp eval_dot_formatter(opts) do cond do dot_formatter = opts[:dot_formatter] -> {dot_formatter, eval_file_with_keyword_list(dot_formatter)} File.regular?(".formatter.exs") -> {".formatter.exs", eval_file_with_keyword_list(".formatter.exs")} true -> {".formatter.exs", []} end end defp eval_file_with_keyword_list(path) do {opts, _} = Code.eval_file(path) unless Keyword.keyword?(opts) do Mix.raise("Expected #{inspect(path)} to return a keyword list, got: #{inspect(opts)}") end opts end defp expand_args([], dot_formatter, formatter_opts_and_subs) do if no_entries_in_formatter_opts?(formatter_opts_and_subs) do Mix.raise( "Expected one or more files/patterns to be given to mix format " <> "or for a .formatter.exs to exist with an :inputs or :subdirectories key" ) end dot_formatter |> expand_dot_inputs([], formatter_opts_and_subs, %{}) |> Enum.map(fn {file, {_dot_formatter, formatter_opts}} -> {file, formatter_opts} end) end defp expand_args(files_and_patterns, _dot_formatter, {formatter_opts, subs}) do files = for file_or_pattern <- files_and_patterns, file <- stdin_or_wildcard(file_or_pattern), uniq: true, do: file if files == [] do Mix.raise( "Could not find a file to format. The files/patterns given to command line " <> "did not point to any existing file. Got: #{inspect(files_and_patterns)}" ) end for file <- files do if file == :stdin do {file, formatter_opts} else split = file |> Path.relative_to_cwd() |> Path.split() {file, find_formatter_opts_for_file(split, {formatter_opts, subs})} end end end defp expand_dot_inputs(dot_formatter, prefix, {formatter_opts, subs}, acc) do if no_entries_in_formatter_opts?({formatter_opts, subs}) do Mix.raise("Expected :inputs or :subdirectories key in #{dot_formatter}") end map = for input <- List.wrap(formatter_opts[:inputs]), file <- Path.wildcard(Path.join(prefix ++ [input]), match_dot: true), do: {expand_relative_to_cwd(file), {dot_formatter, formatter_opts}}, into: %{} acc = Map.merge(acc, map, fn file, {dot_formatter1, _}, {dot_formatter2, formatter_opts} -> Mix.shell().error( "Both #{dot_formatter1} and #{dot_formatter2} specify the file " <> "#{Path.relative_to_cwd(file)} in their :inputs option. To resolve the " <> "conflict, the configuration in #{dot_formatter1} will be ignored. " <> "Please change the list of :inputs in one of the formatter files so only " <> "one of them matches #{Path.relative_to_cwd(file)}" ) {dot_formatter2, formatter_opts} end) Enum.reduce(subs, acc, fn {sub, formatter_opts_and_subs}, acc -> sub_formatter = Path.join(sub, ".formatter.exs") expand_dot_inputs(sub_formatter, [sub], formatter_opts_and_subs, acc) end) end defp expand_relative_to_cwd(path) do case File.cwd() do {:ok, cwd} -> Path.expand(path, cwd) _ -> path end end defp find_formatter_opts_for_file(split, {formatter_opts, subs}) do Enum.find_value(subs, formatter_opts, fn {sub, formatter_opts_and_subs} -> if List.starts_with?(split, Path.split(sub)) do find_formatter_opts_for_file(split, formatter_opts_and_subs) end end) end defp no_entries_in_formatter_opts?({formatter_opts, subs}) do is_nil(formatter_opts[:inputs]) and subs == [] end defp stdin_or_wildcard("-"), do: [:stdin] defp stdin_or_wildcard(path), do: path |> Path.expand() |> Path.wildcard(match_dot: true) defp read_file(file) do {File.read!(file), file: file} end defp format_file({file, _}) do {input, _} = read_file(file) output = X.format_file!(input) write_file(file, input, output) rescue exception -> {:exit, file, exception, __STACKTRACE__} end defp write_file(file, input, output) do cond do input == output -> :ok true -> File.write!(file, output) end :ok end defp collect_status({:ok, :ok}, acc), do: acc defp collect_status({:ok, {:exit, _, _, _} = exit}, exits) do [exit | exits] end defp check!([]) do :ok end defp check!([{:exit, :stdin, exception, stacktrace} | _]) do Mix.shell().error("mix format failed for stdin") reraise exception, stacktrace end defp check!([{:exit, file, exception, stacktrace} | _]) do Mix.shell().error("mix format failed for file: #{Path.relative_to_cwd(file)}") reraise exception, stacktrace end end