lib/mix/tasks/mod.new.ex

defmodule Mix.Tasks.Mod.New do
  use Mix.Task

  @shortdoc "Create a new module in the current application"

  import Modkit.Cli

  def run(argv) do
    Mix.Task.run("app.config")

    {opts, args} =
      command(__MODULE__)
      |> option(:gen_server, :boolean,
        alias: :g,
        doc: "use GenServer and define base functions",
        default: false
      )
      |> option(:supervisor, :boolean,
        alias: :s,
        doc: "use Supervisor and define base functions",
        default: false
      )
      |> option(:dynamic_supervisor, :boolean,
        alias: :d,
        doc: "use DynamicSupervisor and define base functions",
        default: false
      )
      |> option(:mix_task, :boolean,
        alias: :t,
        doc: "create a new mix task",
        default: false
      )
      |> option(:path, :string,
        alias: :p,
        doc: "The path of the module to write. Unnecessary if the module prefix is mounted."
      )
      |> option(:overwrite, :boolean,
        alias: :o,
        doc: "Overwrite the file if it exists. Always prompt.",
        default: false
      )
      |> argument(:module, required: true, cast: &Module.concat([&1]))
      |> parse(argv)

    opts =
      opts
      |> check_exclusive_opts()
      |> add_path_opt(args.module)

    parts_init = %{uses: [], attrs: [], apis: [], impls: [], top_docs: [default_moduledoc()]}

    parts =
      opts
      |> Map.take([:gen_server, :supervisor, :mix_task, :dynamic_supervisor])
      |> Enum.filter(fn {_kind, enabled?} -> enabled? end)
      |> Keyword.keys()
      |> Enum.reduce(parts_init, &collect_parts/2)

    code =
      assemble_parts(args.module, parts)
      |> :erlang.iolist_to_binary()
      |> Code.format_string!(formatter_opts())

    write_code(opts, code)
    success_stop("wrote code to #{opts.path}")
  end

  defp write_code(%{path: path, overwrite: over?}, code) do
    if can_write?(path, over?) do
      :ok = ensure_dir(Path.dirname(path))
      File.write!(path, code)
    else
      abort("file exists: #{path}")
    end
  end

  defp ensure_dir(path) do
    if File.dir?(path) do
      :ok
    else
      ensure_dir(Path.dirname(path))
      File.mkdir!(path)
    end
  end

  defp can_write?(path, prompt?) do
    if File.exists?(path) do
      if prompt? do
        Mix.Shell.IO.yes?("Overwrite file #{path}?", default: :no)
      else
        false
      end
    else
      true
    end
  end

  defp check_exclusive_opts(opts) do
    exclusive = %{
      gen_server: "--gen-server",
      supervisor: "--supervisor",
      mix_task: "--mix-task"
    }

    provided =
      opts
      |> Map.take(Map.keys(exclusive))
      |> Map.filter(fn {_, enabled} -> enabled == true end)
      |> Map.keys()

    case provided do
      [] ->
        opts

      [_single] ->
        opts

      [top | rest] ->
        rest
        |> Enum.map(&Map.fetch!(exclusive, &1))
        |> Enum.intersperse(", ")
        |> Kernel.++([" and ", Map.fetch!(exclusive, top), " are mutually exclusive"])
        |> abort
    end
  end

  defp add_path_opt(%{path: path} = opts, _) when is_binary(path),
    do: opts

  defp add_path_opt(opts, module) do
    # Use the preferred path if mounted or abort

    project = Modkit.Config.current_project()
    mount = Modkit.Config.mount(project)

    case Modkit.Mod.preferred_path(module, mount) do
      {:error, :no_mount_point} ->
        sample_config = Modkit.Config.project_get(project, [:modkit], [])
        new_mount = {module, "lib/path/to/#{Modkit.PathTool.to_snake(module)}"}

        new_conf =
          Keyword.update(sample_config, :mount, [new_mount], fn kw -> kw ++ [new_mount] end)

        abort("""
        The --path option is required when the module prefix is not mounted.

        Please export a mount point from project/0 for the following prefix in mix.exs:

            def project do
              [
                app: #{inspect(Modkit.Config.otp_app(project))},
                # ...
                # ...
                modkit: modkit()
              ]
            end

            defp modkit do
              #{indent(inspect(new_conf, pretty: true), 6)}
            end
        """)

      {:ok, path} ->
        Map.put(opts, :path, path)
    end
  end

  defp indent(text, count) do
    spaces = String.duplicate(" ", count)

    text
    |> String.split("\n")
    |> Enum.map_join("\n", &[spaces, &1])
    |> String.trim()
  end

  defp default_moduledoc do
    ~S(
      @moduledoc """
      TODO Start documenting the module by writing a short description of its purpose.
      """
    )
  end

  def collect_parts(:gen_server, parts) do
    parts
    |> add_part(:uses, "use GenServer")
    |> add_part(:attrs, "@gen_opts ~w(name timeout debug spawn_opt hibernate_after)a")
    |> add_part(:apis, """
        def start_link(opts) do
          {gen_opts, opts} = Keyword.split(opts, @gen_opts)
          GenServer.start_link(__MODULE__, opts, gen_opts)
        end
    """)
    |> add_part(:apis, """
        @impl GenServer
        def init(opts) do
          {:ok, opts}
        end
    """)
  end

  def collect_parts(:supervisor, parts) do
    parts
    |> add_part(:uses, "use Supervisor")
    |> add_part(:attrs, "@gen_opts ~w(name)a")
    |> add_part(:apis, """
        def start_link(opts) do
          {gen_opts, opts} = Keyword.split(opts, @gen_opts)
          Supervisor.start_link(__MODULE__, opts, gen_opts)
        end
    """)
    |> add_part(:apis, """
        @impl Supervisor
        def init(_init_arg) do
          children = [
            {Worker, key: :value}
          ]

          Supervisor.init(children, strategy: :one_for_one)
        end
    """)
  end

  def collect_parts(:dynamic_supervisor, parts) do
    parts
    |> add_part(:uses, "use DynamicSupervisor")
    |> add_part(:attrs, "@gen_opts ~w(name)a")
    |> add_part(:apis, """
        def start_link(opts) do
          {gen_opts, opts} = Keyword.split(opts, @gen_opts)
          DynamicSupervisor.start_link(__MODULE__, opts, gen_opts)
        end
    """)
    |> add_part(:apis, """
        @impl DynamicSupervisor
        def init(_init_arg) do
          DynamicSupervisor.init(strategy: :one_for_one)
        end
    """)
  end

  defmodule Mix.Tasks.Echo do
  end

  def collect_parts(:mix_task, parts) do
    parts
    |> add_part(:top_docs, [
      ~S(@shortdoc "TODO short description of the task")
    ])
    |> add_part(:uses, "use Mix.Task")
    |> add_part(:apis, """
        @impl Mix.Task
        def run(argv) do
          # ...
        end
    """)
  end

  defp add_part(parts, group, code) do
    Map.update!(parts, group, &[code | &1])
  end

  defp assemble_parts(module, %{
         uses: uses,
         attrs: attrs,
         apis: apis,
         impls: impls,
         top_docs: top_docs
       }) do
    [uses, apis, impls, attrs, top_docs] = reverse_lists([uses, apis, impls, attrs, top_docs])

    [
      "defmodule #{inspect(module)} do",
      top_docs,
      uses,
      attrs,
      apis,
      impls,
      "end"
    ]
    |> :lists.flatten()
    |> Enum.intersperse("\n\n")
  end

  defp formatter_opts do
    file = ".formatter.exs"

    if File.regular?(file) do
      {opts, _} = Code.eval_file(file)
      opts
    else
      []
    end
  end

  defp reverse_lists([list | rest]) do
    [:lists.reverse(list) | reverse_lists(rest)]
  end

  defp reverse_lists([]) do
    []
  end
end