lib/bylaw/credo/check/phoenix/use_verified_routes.ex

defmodule Bylaw.Credo.Check.Phoenix.UseVerifiedRoutes do
  @moduledoc """
  Enforces Phoenix verified routes (`~p`) for application routes in the web layer.

  ## Examples

  Configure the Phoenix web boundary and one or more routers that define the
  routes to match:

  ```elixir
  {Bylaw.Credo.Check.Phoenix.UseVerifiedRoutes,
   [
     web_paths: ["lib/my_app_web/"],
     endpoint_paths: ["lib/my_app_web/endpoint.ex"],
     router_paths: ["lib/my_app_web/router.ex"],
     conn_case_modules: [MyAppWeb.ConnCase],
     routers: [MyAppWeb.Router, MyAppWeb.AdminRouter]
   ]}
  ```


  Avoid:

        conn |> get("/api/v1/openapi")

        defp workspace_path(tenant_id, workspace_id) do
          "/api/v1/tenants/\#{tenant_id}/workspaces/\#{workspace_id}"
        end

        assert location == "/api/v1/tenants/\#{tenant.id}/workspaces/\#{workspace.id}"

  Prefer:

        conn |> get(~p"/api/v1/openapi")

        defp workspace_path(tenant_id, workspace_id) do
          ~p"/api/v1/tenants/\#{tenant_id}/workspaces/\#{workspace_id}"
        end

        assert location == ~p"/api/v1/tenants/\#{tenant.id}/workspaces/\#{workspace.id}"

        params = %{filters: %{0 => %{field: "name", op: "==", value: "Staging"}}}
        conn |> get(~p"/api/v1/tenants/\#{tenant_id}/workspaces?\#{params}")

  ## Notes

  The check only runs in configured web paths and tests using configured ConnCase
  modules. It only flags path strings that normalize to a route exposed by one
  of the configured routers or `:fallback_router_paths`. It ignores OpenAPI URI
  templates like `"/api/v1/tenants/{tenant_id}/..."` and HEEx route attributes
  for now.

  This check uses static AST analysis, so it favors clear source-level patterns over runtime behavior.
  Configure `:fallback_router_paths` when router modules are unavailable during Credo analysis.

  ## Options

  Configure options in `.credo.exs` with the check tuple:

  ```elixir
  %{
    configs: [
      %{
        name: "default",
        checks: [
          {Bylaw.Credo.Check.Phoenix.UseVerifiedRoutes,
           [
             web_paths: ["lib/my_app_web/"],
             endpoint_paths: ["lib/my_app_web/endpoint.ex"],
             router_paths: ["lib/my_app_web/router.ex"],
             conn_case_modules: [MyAppWeb.ConnCase],
             routers: [MyAppWeb.Router, MyAppWeb.AdminRouter],
             fallback_router_paths: [
               "/api/v1/openapi",
               "/admin/users/:id"
             ]
           ]}
        ]
      }
    ]
  }
  ```

  - `:web_paths` - Paths containing files where route strings should be checked.
  - `:endpoint_paths` - Endpoint files to skip even when they match `:web_paths`.
  - `:router_paths` - Router files to skip even when they match `:web_paths`.
  - `:conn_case_modules` - Test case modules that identify request/controller tests.
  - `:routers` - Phoenix router modules whose route paths should be matched.
  - `:fallback_router_paths` - Route path patterns to use when router modules are unavailable.

  ## Usage

  Add this check to Credo's `checks:` list in `.credo.exs`:

  ```elixir
  %{
    configs: [
      %{
        name: "default",
        checks: [
          {Bylaw.Credo.Check.Phoenix.UseVerifiedRoutes,
           [
             web_paths: ["lib/my_app_web/"],
             conn_case_modules: [MyAppWeb.ConnCase],
             routers: [MyAppWeb.Router]
           ]}
        ]
      }
    ]
  }
  ```
  """

  use Credo.Check,
    base_priority: :higher,
    category: :warning,
    param_defaults: [
      web_paths: [],
      endpoint_paths: [],
      router_paths: [],
      conn_case_modules: [],
      routers: [],
      fallback_router_paths: []
    ],
    explanations: [
      check: @moduledoc,
      params: [
        web_paths: "Paths containing files where route strings should be checked.",
        endpoint_paths: "Endpoint files to skip even when they match `:web_paths`.",
        router_paths: "Router files to skip even when they match `:web_paths`.",
        conn_case_modules: "Test case modules that identify request/controller tests.",
        routers: "Phoenix router modules whose route paths should be matched.",
        fallback_router_paths: "Route path patterns to use when router modules are unavailable."
      ]
    ]

  @dynamic_marker "__bylaw_dynamic_segment__"
  @navigation_functions [:redirect, :push_navigate, :push_patch]
  @request_functions [:get, :post, :put, :patch, :delete, :head, :options]
  @route_helper_suffixes ["_location", "_path", "_url"]
  @doc false
  @impl Credo.Check
  def run(%Credo.SourceFile{} = source_file, params \\ []) do
    opts = opts(params)

    if web_boundary_file?(source_file, opts) do
      issue_meta = IssueMeta.for(source_file, params)
      router_paths = router_paths(opts)

      source_file
      |> Credo.SourceFile.ast()
      |> find_ast_issues(issue_meta, router_paths)
    else
      []
    end
  end

  defp opts(params) do
    %{
      web_paths: Params.get(params, :web_paths, __MODULE__),
      endpoint_paths: Params.get(params, :endpoint_paths, __MODULE__),
      router_paths: Params.get(params, :router_paths, __MODULE__),
      conn_case_modules: Params.get(params, :conn_case_modules, __MODULE__),
      routers: Params.get(params, :routers, __MODULE__),
      fallback_router_paths: Params.get(params, :fallback_router_paths, __MODULE__)
    }
  end

  defp web_boundary_file?(source_file, opts) do
    filename = source_file.filename

    cond do
      path_matches?(filename, opts.endpoint_paths) ->
        false

      path_matches?(filename, opts.router_paths) ->
        false

      path_matches?(filename, opts.web_paths) ->
        true

      String.ends_with?(filename, "_test.exs") ->
        uses_conn_case?(source_file, opts.conn_case_modules)

      true ->
        false
    end
  end

  defp path_matches?(filename, path_patterns) do
    Enum.any?(path_patterns, fn
      %Regex{} = regex -> Regex.match?(regex, filename)
      path when is_binary(path) -> String.contains?(filename, path)
    end)
  end

  defp uses_conn_case?(source_file, conn_case_modules) do
    conn_case_module_names = module_names(conn_case_modules)

    case Credo.SourceFile.ast(source_file) do
      {:ok, ast} -> ast_uses_conn_case?(ast, conn_case_module_names)
      ast when is_tuple(ast) -> ast_uses_conn_case?(ast, conn_case_module_names)
      _other -> false
    end
  end

  defp ast_uses_conn_case?(ast, conn_case_module_names) do
    ast
    |> Macro.prewalk(false, fn
      {:use, _meta, [{:__aliases__, _aliases_meta, module_parts} | _rest]} = node, _found? ->
        {node, module_name(module_parts) in conn_case_module_names}

      node, found? ->
        {node, found?}
    end)
    |> elem(1)
  end

  defp find_ast_issues({:ok, ast}, issue_meta, router_paths) do
    ast
    |> Macro.prewalk([], &traverse(&1, &2, issue_meta, router_paths))
    |> elem(1)
  end

  defp find_ast_issues(ast, issue_meta, router_paths) when is_tuple(ast) do
    ast
    |> Macro.prewalk([], &traverse(&1, &2, issue_meta, router_paths))
    |> elem(1)
  end

  defp find_ast_issues(_error, _issue_meta, _router_paths), do: []

  defp traverse({fun, meta, args} = ast, issues, issue_meta, router_paths)
       when fun in @request_functions do
    case request_route_expr(args, router_paths) do
      nil ->
        {ast, issues}

      route_expr ->
        {ast, [issue_for(issue_meta, route_expr, meta[:line] || 0) | issues]}
    end
  end

  defp traverse({fun, meta, args} = ast, issues, issue_meta, router_paths)
       when fun in @navigation_functions do
    case keyword_route_expr(args, :to, router_paths) do
      nil ->
        {ast, issues}

      route_expr ->
        {ast, [issue_for(issue_meta, route_expr, meta[:line] || 0) | issues]}
    end
  end

  defp traverse({:put_resp_header, meta, args} = ast, issues, issue_meta, router_paths) do
    case location_header_route_expr(args, router_paths) do
      nil ->
        {ast, issues}

      route_expr ->
        {ast, [issue_for(issue_meta, route_expr, meta[:line] || 0) | issues]}
    end
  end

  defp traverse(
         {fun, meta, [{name, _name_meta, _params}, [do: body]]} = ast,
         issues,
         issue_meta,
         router_paths
       )
       when fun in [:def, :defp] do
    if route_helper_name?(name) and route_expr_matches_router?(body, router_paths) do
      {ast, [issue_for(issue_meta, body, meta[:line] || 0, Atom.to_string(name)) | issues]}
    else
      {ast, issues}
    end
  end

  defp traverse({operator, meta, [left, right]} = ast, issues, issue_meta, router_paths)
       when operator in [:==, :!=, :===, :!==] do
    if route_expr_matches_router?(left, router_paths) or
         route_expr_matches_router?(right, router_paths) do
      route_expr = if route_expr_matches_router?(left, router_paths), do: left, else: right
      {ast, [issue_for(issue_meta, route_expr, meta[:line] || 0) | issues]}
    else
      {ast, issues}
    end
  end

  defp traverse(
         {{:., _dot_meta, [_module, fun]}, meta, args} = ast,
         issues,
         issue_meta,
         router_paths
       )
       when fun in @request_functions do
    case request_route_expr(args, router_paths) do
      nil ->
        {ast, issues}

      route_expr ->
        {ast, [issue_for(issue_meta, route_expr, meta[:line] || 0) | issues]}
    end
  end

  defp traverse(
         {{:., _dot_meta, [_module, fun]}, meta, args} = ast,
         issues,
         issue_meta,
         router_paths
       )
       when fun in @navigation_functions do
    case keyword_route_expr(args, :to, router_paths) do
      nil ->
        {ast, issues}

      route_expr ->
        {ast, [issue_for(issue_meta, route_expr, meta[:line] || 0) | issues]}
    end
  end

  defp traverse(
         {{:., _dot_meta, [_module, :put_resp_header]}, meta, args} = ast,
         issues,
         issue_meta,
         router_paths
       ) do
    case location_header_route_expr(args, router_paths) do
      nil ->
        {ast, issues}

      route_expr ->
        {ast, [issue_for(issue_meta, route_expr, meta[:line] || 0) | issues]}
    end
  end

  defp traverse(ast, issues, _issue_meta, _router_paths), do: {ast, issues}

  defp request_route_expr(args, router_paths) do
    cond do
      route_expr_matches_router?(Enum.at(args, 0), router_paths) -> Enum.at(args, 0)
      route_expr_matches_router?(Enum.at(args, 1), router_paths) -> Enum.at(args, 1)
      true -> nil
    end
  end

  defp keyword_route_expr(args, key, router_paths) do
    route_expr =
      Enum.find_value(args, fn
        list when is_list(list) -> Keyword.get(list, key)
        _other -> nil
      end)

    if route_expr_matches_router?(route_expr, router_paths), do: route_expr, else: nil
  end

  defp location_header_route_expr(args, router_paths) do
    cond do
      Enum.at(args, 0) == "location" and
          route_expr_matches_router?(Enum.at(args, 1), router_paths) ->
        Enum.at(args, 1)

      Enum.at(args, 1) == "location" and
          route_expr_matches_router?(Enum.at(args, 2), router_paths) ->
        Enum.at(args, 2)

      true ->
        nil
    end
  end

  defp route_helper_name?(name) when is_atom(name) do
    name_string = Atom.to_string(name)
    Enum.any?(@route_helper_suffixes, &String.ends_with?(name_string, &1))
  end

  defp route_helper_name?(_other), do: false

  defp issue_for(issue_meta, route_expr, line_no, trigger_override \\ nil) do
    trigger = trigger_override || route_trigger(route_expr)

    format_issue(
      issue_meta,
      message:
        "Use Phoenix verified routes (`~p`) instead of literal or interpolated route strings that match router paths.",
      trigger: trigger,
      line_no: line_no
    )
  end

  defp route_trigger(expr) do
    case route_shape(expr) do
      nil -> Macro.to_string(expr)
      shape -> "/" <> Enum.join(shape, "/")
    end
  end

  defp route_expr_matches_router?(expr, router_paths) do
    case route_shape(expr) do
      nil -> nested_route_expr_matches_router?(expr, router_paths)
      shape -> route_matches_router?(shape, router_paths)
    end
  end

  defp nested_route_expr_matches_router?({:sigil_H, _meta, _args}, _router_paths), do: false
  defp nested_route_expr_matches_router?({:sigil_p, _meta, _args}, _router_paths), do: false

  defp nested_route_expr_matches_router?({form, _meta, args}, router_paths)
       when is_atom(form) and is_list(args) do
    Enum.any?(args, &route_expr_matches_router?(&1, router_paths))
  end

  defp nested_route_expr_matches_router?(list, router_paths) when is_list(list) do
    Enum.any?(list, &route_expr_matches_router?(&1, router_paths))
  end

  defp nested_route_expr_matches_router?({left, right}, router_paths) do
    route_expr_matches_router?(left, router_paths) or
      route_expr_matches_router?(right, router_paths)
  end

  defp nested_route_expr_matches_router?(_other, _router_paths), do: false

  defp route_shape(expr) when is_binary(expr), do: normalize_candidate_path(expr)

  defp route_shape({:<<>>, _meta, parts}) when is_list(parts) do
    parts
    |> Enum.map_join("", &binary_part_to_string/1)
    |> normalize_candidate_path()
  end

  defp route_shape({:__block__, _meta, exprs}) when is_list(exprs) do
    exprs
    |> List.last()
    |> route_shape()
  end

  defp route_shape(_other), do: nil

  defp binary_part_to_string(part) when is_binary(part), do: part
  defp binary_part_to_string(_part), do: @dynamic_marker

  defp normalize_candidate_path(path) when is_binary(path) do
    cond do
      not String.starts_with?(path, "/") -> nil
      String.contains?(path, "{") -> nil
      true -> normalize_path_segments(path)
    end
  end

  defp normalize_router_path(path) do
    stripped_path = strip_query_and_fragment(path)
    normalized_path = Regex.replace(~r/:[a-zA-Z_][a-zA-Z0-9_]*/, stripped_path, @dynamic_marker)

    split_segments(normalized_path)
  end

  defp normalize_path_segments(path) do
    path
    |> strip_query_and_fragment()
    |> split_segments()
    |> Enum.map(fn segment ->
      if String.contains?(segment, @dynamic_marker),
        do: dynamic_markerized_segment(segment),
        else: segment
    end)
  end

  defp dynamic_markerized_segment(segment) do
    Regex.replace(~r/#{Regex.escape(@dynamic_marker)}+/, segment, @dynamic_marker)
  end

  defp strip_query_and_fragment(path) do
    path
    |> String.split(["?", "#"], parts: 2)
    |> hd()
  end

  defp split_segments(path) do
    String.split(path, "/", trim: true)
  end

  defp route_matches_router?(shape, router_paths) do
    MapSet.member?(router_paths, shape)
  end

  defp router_paths(%{routers: routers, fallback_router_paths: fallback_router_paths}) do
    phoenix_router = Module.concat([Phoenix, Router])

    router_paths =
      if Code.ensure_loaded?(phoenix_router) do
        routers
        |> Enum.filter(&Code.ensure_loaded?/1)
        |> Enum.flat_map(fn router ->
          # credo:disable-for-next-line Credo.Check.Refactor.Apply
          apply(phoenix_router, :routes, [router])
        end)
        |> Enum.map(&normalize_router_path(&1.path))
      else
        []
      end

    (router_paths ++ fallback_paths(fallback_router_paths))
    |> MapSet.new()
  end

  defp fallback_paths(fallback_router_paths) do
    fallback_router_paths
    |> Enum.map(&normalize_router_path/1)
  end

  defp module_names(modules), do: Enum.map(modules, &module_name/1)

  defp module_name(module) when is_atom(module) do
    Module.split(module) |> Enum.join(".")
  end

  defp module_name(module) when is_binary(module), do: module

  defp module_name(module_parts) when is_list(module_parts) do
    Enum.map_join(module_parts, ".", &Atom.to_string/1)
  end
end