lib/bylaw/credo/check/elixir/no_result_tuple_argument.ex

defmodule Bylaw.Credo.Check.Elixir.NoResultTupleArgument do
  @moduledoc """
  Prevents functions from accepting `{:ok, _}` or `{:error, _}` as their first
  argument.

  ## Examples

  Avoid:

        def handle({:ok, value}), do: process(value)
        def handle({:error, reason}), do: reason

  Prefer:

        case fetch_value() do
          {:ok, value} -> process(value)
          {:error, reason} -> reason
        end

  ## Notes

  A helper that accepts tagged result tuples mixes two responsibilities:
  branching on the result shape and doing the work for the successful or error
  value. That makes the helper harder to reuse with plain values.

  Branch where the tagged result is produced, then call helpers with the value
  or reason they actually operate on.

  Path exclusions are matched against the source filename and are intended for generated files or temporary migration areas.

  The check uses static AST analysis, so dynamic code generation and macro-expanded code may fall outside its signal.

  ## Options

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

  ```elixir
  %{
    configs: [
      %{
        name: "default",
        checks: [
          {Bylaw.Credo.Check.Elixir.NoResultTupleArgument,
           [
             excluded_paths: ["test/support/"]
           ]}
        ]
      }
    ]
  }
  ```

  - `:excluded_paths` - List of paths or regexes to exclude from this check

  ## Usage

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

  ```elixir
  %{
    configs: [
      %{
        name: "default",
        checks: [
          {Bylaw.Credo.Check.Elixir.NoResultTupleArgument, []}
        ]
      }
    ]
  }
  ```
  """

  use Credo.Check,
    base_priority: :high,
    category: :warning,
    param_defaults: [excluded_paths: []],
    explanations: [
      check: @moduledoc,
      params: [
        excluded_paths: "List of paths or regexes to exclude from this check"
      ]
    ]

  @result_tags [:ok, :error]
  @doc false
  @impl Credo.Check
  def run(%Credo.SourceFile{} = source_file, params \\ []) do
    excluded_paths = Params.get(params, :excluded_paths, __MODULE__)

    if ignored_path?(source_file.filename, excluded_paths) do
      []
    else
      issue_meta = IssueMeta.for(source_file, params)
      Credo.Code.prewalk(source_file, &traverse(&1, &2, issue_meta))
    end
  end

  defp traverse(
         {fun, meta, [{:when, _when_meta, [{_name, _name_meta, params} | _guards]} | _body]} = ast,
         issues,
         issue_meta
       )
       when fun in [:def, :defp] and is_list(params) do
    {ast, maybe_add_issue(List.first(params), issues, issue_meta, meta)}
  end

  defp traverse(
         {fun, meta, [{_name, _name_meta, params} | _body]} = ast,
         issues,
         issue_meta
       )
       when fun in [:def, :defp] and is_list(params) do
    {ast, maybe_add_issue(List.first(params), issues, issue_meta, meta)}
  end

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

  defp maybe_add_issue(nil, issues, _issue_meta, _meta), do: issues

  defp maybe_add_issue(param, issues, issue_meta, meta) do
    case find_result_tuple(param) do
      nil ->
        issues

      tuple_pattern ->
        [issue_for(issue_meta, meta[:line] || 0, tuple_pattern) | issues]
    end
  end

  defp find_result_tuple({:=, _meta, [left, right]}) do
    find_result_tuple(left) || find_result_tuple(right)
  end

  defp find_result_tuple({tag, _value} = tuple) when tag in @result_tags, do: tuple

  defp find_result_tuple({:{}, _meta, [tag, _value | _rest]} = tuple) when tag in @result_tags,
    do: tuple

  defp find_result_tuple(_other), do: nil

  defp issue_for(issue_meta, line_no, tuple_pattern) do
    trigger = Macro.to_string(tuple_pattern)

    format_issue(
      issue_meta,
      message:
        "Do not accept `#{trigger}` as the first function argument. Branch on the result " <>
          "earlier with `case` or `with`, then pass the unwrapped value or reason to a " <>
          "dedicated function.",
      trigger: trigger,
      line_no: line_no
    )
  end

  defp ignored_path?(filename, excluded_paths) do
    Enum.any?(excluded_paths, &matches_path?(filename, &1))
  end

  defp matches_path?(filename, %Regex{} = regex), do: Regex.match?(regex, filename)
  defp matches_path?(filename, path) when is_binary(path), do: String.contains?(filename, path)
end