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

defmodule Bylaw.Credo.Check.Elixir.UseMaybeInFunctionName do
  @moduledoc """
  Use `maybe_` in function names that only perform work conditionally.

  ## Examples

  Avoid:

        def complete_run_if_needed(run), do: ...
  Prefer:

        def maybe_complete_run(run), do: ...

  A leading `maybe_` keeps the conditional intent visible without coupling
  the naming convention to a specific suffix like `_if_needed`.

  ## Notes

  This check uses static AST analysis, so it favors clear source-level patterns over runtime behavior.

  ## Options

  This check has no check-specific options. Configure it with an empty option list.

  ## Usage

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

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

  use Credo.Check,
    base_priority: :high,
    category: :readability,
    explanations: [
      check: @moduledoc
    ]

  @named_definitions [:def, :defp, :defmacro, :defmacrop, :defdelegate]
  @conditional_name_patterns [
    ~r/^(?<stem>.+)_if_needed(?<suffix>[!?])?$/
  ]
  @doc false
  @impl Credo.Check
  def run(source_file, params \\ []) do
    initial_state = %{
      ctx: Context.build(source_file, params, __MODULE__),
      seen_signatures: MapSet.new()
    }

    result = Credo.Code.prewalk(source_file, &walk/2, initial_state)
    result.ctx.issues
  end

  defp walk({definition, _meta, arguments} = ast, state)
       when definition in @named_definitions and is_list(arguments) do
    case extract_signature(List.first(arguments)) do
      nil ->
        {ast, state}

      {name, line_no, arity} ->
        maybe_add_issue(ast, state, name, line_no, arity)
    end
  end

  defp walk(ast, state), do: {ast, state}

  defp maybe_add_issue(ast, state, name, line_no, arity) do
    signature = {name, arity}

    cond do
      MapSet.member?(state.seen_signatures, signature) ->
        {ast, state}

      suggestion = maybe_name_suggestion(name) ->
        issue =
          format_issue(
            state.ctx,
            message:
              "Use `maybe_` in conditional function names. Rename `#{name}` to `#{suggestion}`.",
            trigger: name,
            line_no: line_no
          )

        state = %{
          state
          | ctx: put_issue(state.ctx, issue),
            seen_signatures: MapSet.put(state.seen_signatures, signature)
        }

        {ast, state}

      true ->
        {ast, %{state | seen_signatures: MapSet.put(state.seen_signatures, signature)}}
    end
  end

  defp extract_signature({:when, _meta, [call, _guard]}), do: extract_signature(call)

  defp extract_signature({name, meta, args})
       when is_atom(name) and (is_list(args) or is_nil(args)) do
    arity =
      args
      |> List.wrap()
      |> Enum.count()

    {Atom.to_string(name), meta[:line], arity}
  end

  defp extract_signature(_ast), do: nil

  defp maybe_name_suggestion(name) do
    Enum.find_value(@conditional_name_patterns, fn pattern ->
      case Regex.named_captures(pattern, name) do
        captures when is_map(captures) ->
          build_maybe_name(captures)

        nil ->
          nil
      end
    end)
  end

  defp build_maybe_name(captures) do
    stem = Map.get(captures, "stem")
    suffix = Map.get(captures, "suffix", "")

    "maybe_#{stem}#{suffix}"
  end
end