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

defmodule Bylaw.Credo.Check.Elixir.NoExtraPublicBehaviourFunctions do
  @moduledoc """
  Reports public functions on selected behaviour implementations when those
  functions are not callbacks of the implemented behaviours.

  ## Examples

  This check is opt-in by behaviour. Configure only the behaviours whose
  implementations should expose a minimal public API:

        {Bylaw.Credo.Check.Elixir.NoExtraPublicBehaviourFunctions,
         [
           behaviours: [
             MyApp.Workers.Job,
             MyApp.Notifications.Delivery
           ],
           allowed: []
         ]}

  Callback signatures are read from each behaviour module with
  `behaviour_info(:callbacks)`, so the callback list should not be duplicated in
  Credo configuration. Use `:allowed` for intentional extra public functions.

  ## Notes

  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.NoExtraPublicBehaviourFunctions,
           [
             behaviours: [MyApp.Workers.Job],
             allowed: [child_spec: 1],
             excluded_paths: ["test/support/"]
           ]}
        ]
      }
    ]
  }
  ```

  - `:behaviours` - Behaviours whose implementations should expose only callback public functions.
  - `:allowed` - Keyword list of intentional extra public functions, such as `[child_spec: 1]`.
  - `:excluded_paths` - List of path prefixes 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.NoExtraPublicBehaviourFunctions, []}
        ]
      }
    ]
  }
  ```
  """

  use Credo.Check,
    base_priority: :high,
    category: :design,
    param_defaults: [behaviours: [], allowed: [], excluded_paths: []],
    explanations: [
      check: @moduledoc,
      params: [
        behaviours:
          "Behaviours whose implementations should expose only callback public functions.",
        allowed: "Keyword list of intentional extra public functions, such as `[child_spec: 1]`.",
        excluded_paths: "List of path prefixes or regexes to exclude from this check."
      ]
    ]

  alias Credo.SourceFile

  @doc false
  @impl Credo.Check
  @spec run(SourceFile.t(), Keyword.t()) :: list(Credo.Issue.t())
  def run(%SourceFile{} = source_file, params \\ []) do
    excluded_paths = Params.get(params, :excluded_paths, __MODULE__)

    if excluded?(source_file.filename, excluded_paths) do
      []
    else
      issue_meta = IssueMeta.for(source_file, params)

      state = %{
        issue_meta: issue_meta,
        behaviours: MapSet.new(Params.get(params, :behaviours, __MODULE__)),
        callbacks_by_behaviour: callbacks_by_behaviour(params),
        allowed: allowed_signatures(params),
        issues: []
      }

      source_file
      |> SourceFile.ast()
      |> find_issues(state)
    end
  end

  defp find_issues({:ok, ast}, state), do: find_issues(ast, state)

  defp find_issues(ast, state) when is_tuple(ast) do
    ast
    |> Macro.prewalk(state, &traverse/2)
    |> elem(1)
    |> Map.fetch!(:issues)
    |> Enum.reverse()
  end

  defp find_issues(_other, _state), do: []

  defp traverse({:defmodule, _meta, [_module_ast, [do: body]]} = ast, state) do
    {ast, report_module(body, state)}
  end

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

  defp report_module(body, state) do
    aliases = aliases(body)

    implemented_behaviours =
      body
      |> direct_children()
      |> Enum.flat_map(&behaviour_attribute(&1, aliases))
      |> Enum.filter(&MapSet.member?(state.behaviours, &1))

    allowed_callbacks = callback_signatures(implemented_behaviours, state.callbacks_by_behaviour)

    if Enum.empty?(allowed_callbacks) do
      state
    else
      body
      |> direct_children()
      |> Enum.flat_map(&public_definition_signatures/1)
      |> Enum.reject(&MapSet.member?(allowed_callbacks, signature(&1)))
      |> Enum.reject(&MapSet.member?(state.allowed, signature(&1)))
      |> Enum.uniq_by(&signature/1)
      |> Enum.reduce(state, &add_issue/2)
    end
  end

  defp callbacks_by_behaviour(params) do
    params
    |> Params.get(:behaviours, __MODULE__)
    |> Enum.reduce(%{}, fn behaviour, callbacks_by_behaviour ->
      Map.put(callbacks_by_behaviour, behaviour, behaviour_callbacks(behaviour))
    end)
  end

  defp behaviour_callbacks(behaviour) when is_atom(behaviour) do
    if Code.ensure_loaded?(behaviour) and function_exported?(behaviour, :behaviour_info, 1) do
      behaviour
      |> apply(:behaviour_info, [:callbacks])
      |> MapSet.new()
    else
      MapSet.new()
    end
  rescue
    _error -> MapSet.new()
  end

  defp behaviour_callbacks(_behaviour), do: MapSet.new()

  defp callback_signatures(behaviours, callbacks_by_behaviour) do
    behaviours
    |> Enum.map(&Map.get(callbacks_by_behaviour, &1, MapSet.new()))
    |> Enum.reduce(MapSet.new(), &MapSet.union/2)
  end

  defp allowed_signatures(params) do
    params
    |> Params.get(:allowed, __MODULE__)
    |> Enum.flat_map(fn
      {name, arity} when is_atom(name) and is_integer(arity) -> [{name, arity}]
      _other -> []
    end)
    |> MapSet.new()
  end

  defp direct_children({:__block__, _meta, children}), do: children
  defp direct_children(nil), do: []
  defp direct_children(child), do: [child]

  defp aliases(body) do
    body
    |> direct_children()
    |> Enum.flat_map(&alias_entries/1)
    |> Map.new()
  end

  defp alias_entries({:alias, _meta, [{:__aliases__, _aliases_meta, aliases}]}) do
    [{List.last(aliases), Module.concat(aliases)}]
  end

  defp alias_entries(
         {:alias, _meta, [{:__aliases__, _aliases_meta, aliases}, [as: {:__aliases__, _, [as]}]]}
       ) do
    [{as, Module.concat(aliases)}]
  end

  defp alias_entries(_ast), do: []

  defp behaviour_attribute({:@, _meta, [{:behaviour, _attribute_meta, [behaviour_ast]}]}, aliases) do
    case module_attribute_value(behaviour_ast, aliases) do
      {:ok, behaviour} -> [behaviour]
      :error -> []
    end
  end

  defp behaviour_attribute(_ast, _aliases), do: []

  defp module_attribute_value({:__aliases__, _meta, [alias]}, aliases) do
    {:ok, Map.get(aliases, alias, alias)}
  end

  defp module_attribute_value({:__aliases__, _meta, [alias | rest]}, aliases) do
    root = Map.get(aliases, alias, alias)
    {:ok, Module.concat([root | rest])}
  end

  defp module_attribute_value(behaviour, _aliases) when is_atom(behaviour), do: {:ok, behaviour}
  defp module_attribute_value(_ast, _aliases), do: :error

  defp public_definition_signatures({:def, _meta, [head | _body]}) do
    case definition_head(head) do
      {:ok, name, meta, params} ->
        params
        |> public_arities()
        |> Enum.map(&%{name: name, arity: &1, line_no: meta[:line] || 0})

      :error ->
        []
    end
  end

  defp public_definition_signatures({:defdelegate, _meta, [head | _opts]}) do
    case definition_head(head) do
      {:ok, name, meta, params} ->
        params
        |> public_arities()
        |> Enum.map(&%{name: name, arity: &1, line_no: meta[:line] || 0})

      :error ->
        []
    end
  end

  defp public_definition_signatures(_ast), do: []

  defp definition_head({:when, _meta, [call | _guards]}), do: definition_head(call)

  defp definition_head({name, meta, params}) when is_atom(name) and is_list(params) do
    {:ok, name, meta, params}
  end

  defp definition_head(_head), do: :error

  defp public_arities(params) do
    arity = Enum.count(params)
    default_count = Enum.count(params, &default_argument?/1)

    (arity - default_count)..arity
    |> Enum.to_list()
    |> Enum.uniq()
  end

  defp default_argument?({:\\, _meta, [_param, _default]}), do: true
  defp default_argument?(_param), do: false

  defp add_issue(%{name: name, arity: arity, line_no: line_no}, state) do
    issue =
      format_issue(
        state.issue_meta,
        message:
          "Public function `#{name}/#{arity}` is not a callback of the configured behaviour. " <>
            "Make it private, add a callback to the behaviour, or list it in `:allowed`.",
        trigger: "#{name}/#{arity}",
        line_no: line_no
      )

    %{state | issues: [issue | state.issues]}
  end

  defp signature(%{name: name, arity: arity}), do: {name, arity}

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