lib/mix/tasks/phx.routes.ex

defmodule Mix.Tasks.Phx.Routes do
  use Mix.Task
  alias Phoenix.Router.ConsoleFormatter

  @shortdoc "Prints all routes"

  @moduledoc """
  Prints all routes for the default or a given router.
  Can also locate the controller function behind a specified url.

      $ mix phx.routes [ROUTER] [--info URL]

  The default router is inflected from the application
  name unless a configuration named `:namespace`
  is set inside your application configuration. For example,
  the configuration:

      config :my_app,
        namespace: My.App

  will exhibit the routes for `My.App.Router` when this
  task is invoked without arguments.

  Umbrella projects do not have a default router and
  therefore always expect a router to be given. An
  alias can be added to mix.exs to automate this:

      defp aliases do
        [
          "phx.routes": "phx.routes MyAppWeb.Router",
          # aliases...
        ]

  ## Options

    * `--info` - locate the controller function definition called by the given url
    * `--method` - what HTTP method to use with the given url, only works when used with `--info` and defaults to `get`

  ## Examples

  Print all routes for the default router:

      $ mix phx.routes

  Print all routes for the given router:

      $ mix phx.routes MyApp.AnotherRouter

  Print information about the controller function called by a specified url:

      $ mix phx.routes --info http://0.0.0.0:4000/home
        Module: RouteInfoTestWeb.PageController
        Function: :index
        /home/my_app/controllers/page_controller.ex:4

  Print information about the controller function called by a specified url and HTTP method:

      $ mix phx.routes --info http://0.0.0.0:4000/users --method post
        Module: RouteInfoTestWeb.UserController
        Function: :create
        /home/my_app/controllers/user_controller.ex:24
  """

  @doc false
  def run(args, base \\ Mix.Phoenix.base()) do
    if "--no-compile" not in args do
      Mix.Task.run("compile")
    end

    Mix.Task.reenable("phx.routes")

    {opts, args, _} =
      OptionParser.parse(args, switches: [endpoint: :string, router: :string, info: :string])

    {router_mod, endpoint_mod} =
      case args do
        [passed_router] -> {router(passed_router, base), opts[:endpoint]}
        [] -> {router(opts[:router], base), endpoint(opts[:endpoint], base)}
      end

    case Keyword.fetch(opts, :info) do
      {:ok, url} ->
        get_url_info(url, {router_mod, opts})

      :error ->
        router_mod
        |> ConsoleFormatter.format(endpoint_mod)
        |> Mix.shell().info()
    end
  end

  def get_url_info(url, {router_mod, opts}) do
    %{path: path} = URI.parse(url)

    method = opts |> Keyword.get(:method, "get") |> String.upcase()
    meta = Phoenix.Router.route_info(router_mod, method, path, "")
    %{plug: plug, plug_opts: plug_opts} = meta

    {module, func_name} =
      if log_mod = meta[:log_module] do
        {log_mod, meta[:log_function]}
      else
        {plug, plug_opts}
      end

    Mix.shell().info("Module: #{inspect(module)}")
    if func_name, do: Mix.shell().info("Function: #{inspect(func_name)}")

    file_path = get_file_path(module)

    if line = get_line_number(module, func_name) do
      Mix.shell().info("#{file_path}:#{line}")
    else
      Mix.shell().info("#{file_path}")
    end
  end

  defp endpoint(nil, base) do
    loaded(web_mod(base, "Endpoint"))
  end

  defp endpoint(module, _base) do
    loaded(Module.concat([module]))
  end

  defp router(nil, base) do
    if Mix.Project.umbrella?() do
      Mix.raise("""
      umbrella applications require an explicit router to be given to phx.routes, for example:

          $ mix phx.routes MyAppWeb.Router

      An alias can be added to mix.exs aliases to automate this:

          "phx.routes": "phx.routes MyAppWeb.Router"

      """)
    end

    web_router = web_mod(base, "Router")
    old_router = app_mod(base, "Router")

    loaded(web_router) || loaded(old_router) ||
      Mix.raise("""
      no router found at #{inspect(web_router)} or #{inspect(old_router)}.
      An explicit router module may be given to phx.routes, for example:

          $ mix phx.routes MyAppWeb.Router

      An alias can be added to mix.exs aliases to automate this:

          "phx.routes": "phx.routes MyAppWeb.Router"

      """)
  end

  defp router(router_name, _base) do
    arg_router = Module.concat([router_name])
    loaded(arg_router) || Mix.raise("the provided router, #{inspect(arg_router)}, does not exist")
  end

  defp loaded(module) do
    if Code.ensure_loaded?(module), do: module
  end

  defp app_mod(base, name), do: Module.concat([base, name])

  defp web_mod(base, name), do: Module.concat(["#{base}Web", name])

  defp get_file_path(module_name) do
    [compile_infos] = Keyword.get_values(module_name.module_info(), :compile)
    [source] = Keyword.get_values(compile_infos, :source)
    source
  end

  defp get_line_number(_, nil), do: nil

  defp get_line_number(module, function_name) do
    {_, _, _, _, _, _, functions_list} = Code.fetch_docs(module)

    function_infos =
      functions_list
      |> Enum.find(fn {{type, name, _}, _, _, _, _} ->
        type == :function and name == function_name
      end)

    case function_infos do
      {_, anno, _, _, _} -> :erl_anno.line(anno)
      nil -> nil
    end
  end
end