lib/mix/tasks/events.ex

defmodule Mix.Tasks.Events do
  @moduledoc ~S"""
  Manage Kvasir events.
  """
  use Mix.Task
  @preferred_cli_env :dev
  @shortdoc ~S"Manage Kvasir events."
  @strict [major: :boolean, minor: :boolean, patch: :boolean, changelog: :string]
  @impl Mix.Task
  def run(args) do
    events = events()

    case OptionParser.parse(args, strict: @strict) do
      {opts, ["bump", event | _], _} -> bump(events, event, opts)
      {_opts, ["list" | _], _} -> list(events)
      {_opts, [name], _} -> show(events, name)
      _ -> list(events)
    end
  end

  defp columns do
    {columns, 0} = System.cmd("tput", ["cols"])
    String.to_integer(String.trim(columns))
  end

  defp header(header, opts \\ []) do
    bg = Keyword.get(opts, :bg, IO.ANSI.light_yellow_background())
    fg = Keyword.get(opts, :fg, IO.ANSI.black())
    max_width = columns()
    length = String.length(header)

    v =
      case Keyword.get(opts, :align, :center) do
        :center ->
          header
          |> String.pad_trailing(length + div(max_width - length, 2))
          |> String.pad_leading(max_width)

        :left ->
          String.pad_trailing("  " <> header, max_width)

        :right ->
          String.pad_leading(header <> "  ", max_width)
      end

    [bg, fg, v, IO.ANSI.reset(), ?\n]
  end

  defp show(events, event) do
    if e = Enum.find(events, &(inspect(&1) == event || &1.__event__(:type) == event)) do
      source = to_string(e.__info__(:compile)[:source])

      Code.compiler_options(ignore_module_conflict: true)
      Code.compile_file(source)
      Code.compiler_options(ignore_module_conflict: false)

      name = "  " <> inspect(e) <> "  "
      relative = Path.relative_to_cwd(source)
      path = if(String.length(relative) < String.length(source), do: relative, else: source)

      fields =
        Enum.reduce(e.__event__(:fields), [], fn {field, type, opts}, acc ->
          [
            [
              " - #{field}",
              IO.ANSI.italic(),
              " (#{type.name()})",
              IO.ANSI.reset(),
              ?\t,
              List.first(String.split(opts[:doc] || "", "\n")) || "",
              ?\n
            ]
            | acc
          ]
        end)

      history =
        Enum.reduce(e.__event__(:history), [], fn {v, d, doc}, acc ->
          [
            [
              ">> v#{v}",
              IO.ANSI.italic(),
              if(d, do: " (#{d})", else: ""),
              IO.ANSI.reset(),
              ?\n,
              doc,
              ?\n
            ]
            | acc
          ]
        end)

      IO.puts([
        header(name),
        "File:  ",
        IO.ANSI.italic(),
        path,
        IO.ANSI.reset(),
        ?\n,
        "Type:  ",
        IO.ANSI.italic(),
        e.__event__(:type),
        IO.ANSI.reset(),
        ?\n,
        "Error: ",
        IO.ANSI.italic(),
        to_string(e.__event__(:on_error)),
        IO.ANSI.reset(),
        ?\n,
        ?\n,
        e.__event__(:doc),
        ?\n,
        header("Fields", align: :left, bg: IO.ANSI.yellow_background()),
        ?\n,
        :lists.reverse(fields),
        ?\n,
        header("History", align: :left, bg: IO.ANSI.yellow_background()),
        ?\n,
        history
      ])
    else
      IO.puts(:stderr, [IO.ANSI.red(), "Unknown event: #{event}"])
      Sys
    end
  end

  defp bump(events, event, opts) do
    if e = Enum.find(events, &(inspect(&1) == event || &1.__event__(:type) == event)) do
      source = to_string(e.__info__(:compile)[:source])

      Code.compiler_options(ignore_module_conflict: true)
      Code.compile_file(source)
      Code.compiler_options(ignore_module_conflict: false)

      current = e.__event__(:version)
      data = File.read!(source)
      [{split, _} | _] = Regex.run(~r/  event [a-z._]+ do/, data, return: :index)
      {a, b} = String.split_at(data, split)
      now = NaiveDateTime.utc_now()

      changelog =
        opts
        |> Keyword.get(:changelog, "")
        |> String.trim_trailing("\n")
        |> String.split("\n")
        |> Enum.join("\n  ")

      new =
        cond do
          opts[:major] ->
            %{current | major: current.major + 1, minor: 0, patch: 0}

          opts[:minor] ->
            %{current | minor: current.minor + 1, patch: 0}

          opts[:patch] ->
            %{current | patch: current.patch + 1}

          :no_bump ->
            IO.puts(:stderr, [IO.ANSI.red(), "Need to pass `--major`, `--minor`, or `--patch`."])
            System.halt(2)
        end

      [{split, _} | _] =
        Regex.run(Regex.compile!(~s/((upgrade "~> #{current.major - 1}.0")|(end\s*$))/), b,
          return: :index
        )

      {b1, b2} = String.split_at(b, split)

      File.write!(source, [
        a,
        ~s|  @doc ~S"""  \n  #{changelog}\n  """\n  version "#{new}", #{inspect(now)}\n\n|,
        b1,
        ~s/\n  upgrade "~> #{current.major}.0" do\n    {:ok, event}\n  end\n/,
        b2
      ])

      Mix.Task.run("format")
    else
      IO.puts(:stderr, [IO.ANSI.red(), "Unknown event: #{event}"])
      System.halt(1)
    end
  end

  defp list(events) do
    events
    |> Enum.map(
      &[
        inspect(&1),
        to_string(&1.__event__(:version)),
        &1.__event__(:type),
        to_string(&1.__event__(:on_error)),
        :doc |> &1.__event__() |> Kernel.||("") |> String.split("\n") |> List.first()
      ]
    )
    |> Enum.sort_by(&List.first/1)
    |> format_table(["Event", "Version", "Type", "On Error", "Description"])
    |> IO.puts()
  end

  defp format_table(data, columns, dist \\ 2) do
    base = Enum.map(columns, &String.length/1)

    widths =
      Enum.reduce(data, base, fn row, acc -> merge_map(row, acc, &max(String.length(&1), &2)) end)

    width = Enum.sum(widths) + (Enum.count(widths) - 1) * dist

    # {:ok, max_width} = :io.columns()
    # retract = if width > max_width, do: max_width - (width + 3), else: 0

    [
      merge_map(columns, widths, &String.pad_trailing(&1, &2 + dist)),
      ?\n,
      String.duplicate("-", width),
      ?\n,
      data
      |> Enum.map(fn row -> merge_map(row, widths, &String.pad_trailing(&1, &2 + dist)) end)
      |> Enum.intersperse(?\n)
    ]
  end

  defp merge_map(a, b, fun, acc \\ [])
  defp merge_map([], [], _fun, acc), do: :lists.reverse(acc)
  defp merge_map([x | xs], [y | ys], fun, acc), do: merge_map(xs, ys, fun, [fun.(x, y) | acc])

  @spec events :: [module]
  defp events, do: Enum.filter(ApplicationX.modules(), &is_event?/1)

  @spec is_event?(module) :: boolean
  defp is_event?(module) do
    Keyword.has_key?(module.__info__(:attributes), :__event__)
  rescue
    _ -> false
  end
end