Skip to main content

lib/mix/tasks/thx_lint.install.ex

defmodule Mix.Tasks.ThxLint.Install do
  @moduledoc """
  Installs The Thracian Credo configuration into an Elixir project.
  """

  use Mix.Task

  @shortdoc "Installs The Thracian Credo configuration"
  @new_block "the_thracian_credo"
  @legacy_block "@thethracian/elixir-lint-config"
  @version Mix.Project.config()[:version]
  @formatter """
  [
    line_length: 150,
    trailing_comma: true,
    inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
  ]
  """
  @dialyzer_ignore "[]\n"
  @doctor """
  %Doctor.Config{
    ignore_modules: [],
    ignore_paths: [],
    min_module_doc_coverage: 40,
    min_module_spec_coverage: 0,
    min_overall_doc_coverage: 100,
    min_overall_moduledoc_coverage: 100,
    min_overall_spec_coverage: 100,
    exception_moduledoc_required: true,
    raise: false,
    reporter: Doctor.Reporters.Full,
    struct_type_spec_required: true,
    umbrella: false
  }
  """

  @impl true
  def run(args) do
    {opts, _argv, invalid} = OptionParser.parse(args, switches: [cwd: :string, force: :boolean, yes: :boolean])

    if invalid != [] do
      Mix.raise("Unknown option(s): #{inspect(invalid)}")
    end

    cwd = opts |> Keyword.get(:cwd, File.cwd!()) |> Path.expand()
    force? = Keyword.get(opts, :force, false)

    install_credo(cwd, force?)
    install_formatter(cwd, force?)
    write_managed_file(Path.join(cwd, ".dialyzer_ignore.exs"), @dialyzer_ignore, force?)
    write_managed_file(Path.join(cwd, ".doctor.exs"), @doctor, force?)

    Mix.shell().info("Installed The Thracian Elixir lint setup in #{cwd}")
  end

  defp install_credo(cwd, force?) do
    target = Path.join(cwd, ".credo.exs")
    body = TheThracianCredo.default_config()

    cond do
      force? or not File.exists?(target) ->
        write_managed_file(target, body, true)

      managed?(File.read!(target)) ->
        write_managed_file(target, body, false)

      true ->
        patch_existing_credo(target)
    end
  end

  defp install_formatter(cwd, force?) do
    target = Path.join(cwd, ".formatter.exs")

    cond do
      force? or not File.exists?(target) ->
        write_managed_file(target, @formatter, true)

      managed?(File.read!(target)) ->
        write_managed_file(target, @formatter, false)

      true ->
        File.write!(target, patch_formatter(File.read!(target)))
    end
  end

  defp write_managed_file(target, body, force?) do
    File.mkdir_p!(Path.dirname(target))
    block = managed_block(body)

    cond do
      not File.exists?(target) ->
        File.write!(target, block)

      managed?(File.read!(target)) ->
        File.write!(target, replace_managed_region(File.read!(target), block))

      force? ->
        File.write!(target, block)

      true ->
        Mix.shell().info("Skipping #{target}; existing file is not managed by #{@new_block}.")
    end
  end

  defp patch_existing_credo(target) do
    content = File.read!(target)

    target
    |> File.write!(content |> add_plugin() |> add_checks())
  end

  defp add_plugin(content) do
    cond do
      content =~ "{TheThracianCredo" ->
        content

      content =~ ~r/plugins:\s*\[\s*\]/ ->
        Regex.replace(~r/plugins:\s*\[\s*\]/, content, "plugins: [{TheThracianCredo, []}]", global: false)

      content =~ ~r/plugins:\s*\[/ ->
        Regex.replace(~r/plugins:\s*\[/, content, "plugins: [{TheThracianCredo, []}, ", global: false)

      true ->
        Mix.raise("Could not find a plugins: list in .credo.exs. Add {TheThracianCredo, []} manually.")
    end
  end

  defp add_checks(content) do
    cond do
      content =~ "TheThracianCredo.checks()" ->
        content

      content =~ ~r/enabled:\s*\[/ ->
        Regex.replace(~r/enabled:\s*\[/, content, "enabled: TheThracianCredo.checks() ++ [", global: false)

      content =~ ~r/extra:\s*\[/ ->
        Regex.replace(~r/extra:\s*\[/, content, "extra: TheThracianCredo.checks() ++ [", global: false)

      content =~ ~r/checks:\s*%\{\s*/ ->
        Regex.replace(~r/checks:\s*%\{\s*/, content, "checks: %{\n            extra: TheThracianCredo.checks(), ", global: false)

      true ->
        Mix.raise("Could not find a checks: map in .credo.exs. Add TheThracianCredo.checks() manually.")
    end
  end

  defp patch_formatter(content) do
    content
    |> add_formatter_option("line_length", "line_length: 150")
    |> add_formatter_option("trailing_comma", "trailing_comma: true")
  end

  defp add_formatter_option(content, key, line) do
    if content =~ "#{key}:" do
      content
    else
      Regex.replace(~r/^\s*\[/, content, "[\n  #{line},", global: false)
    end
  end

  defp managed?(content) do
    content =~ "# BEGIN #{@new_block}" or content =~ "# BEGIN #{@legacy_block}"
  end

  defp replace_managed_region(content, block) do
    content
    |> replace_region(@new_block, block)
    |> replace_region(@legacy_block, block)
  end

  defp replace_region(content, block_name, block) do
    pattern = ~r/# BEGIN #{Regex.escape(block_name)}[\s\S]*?# END #{Regex.escape(block_name)}/
    Regex.replace(pattern, content, String.trim_trailing(block), global: false)
  end

  defp managed_block(body) do
    """
    # BEGIN #{@new_block}
    # VERSION #{@version}
    #{String.trim(body)}
    # END #{@new_block}
    """
  end
end