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

defmodule Bylaw.Credo.Check.Elixir.DocBeforeSpec do
  @moduledoc """
  Requires `@doc` to appear before `@spec` for public function definitions.

  ## Examples

  Avoid:

        @spec handle(result :: term()) :: :ok
        @doc "Handles the result."
        def handle(result), do: :ok
  Prefer:

        @doc "Handles the result."
        @spec handle(result :: term()) :: :ok
        def handle(result), do: :ok

  ## 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.DocBeforeSpec, []}
        ]
      }
    ]
  }
  ```
  """

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

  @public_definitions [:def, :defguard, :defmacro]
  @doc false
  @impl Credo.Check
  def run(%Credo.SourceFile{} = source_file, params \\ []) do
    issue_meta = IssueMeta.for(source_file, params)

    source_file
    |> Credo.SourceFile.ast()
    |> find_issues(issue_meta)
  end

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

  defp find_issues(ast, issue_meta) when is_tuple(ast) do
    case Macro.prewalk(ast, [], &traverse(&1, &2, issue_meta)) do
      {_ast, issues} -> Enum.reverse(issues)
    end
  end

  defp find_issues(_ast, _issue_meta), do: []

  defp traverse({:defmodule, _meta, [_name, [do: body]]} = node, issues, issue_meta) do
    {node, issues_for_body(body, issue_meta) ++ issues}
  end

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

  defp issues_for_body(body, issue_meta) do
    body
    |> body_expressions()
    |> Enum.reduce({[], []}, fn expression, {pending_attrs, issues} ->
      cond do
        attribute?(expression) ->
          {collect_relevant_attribute(expression, pending_attrs), issues}

        public_definition?(expression) ->
          {[], maybe_issue_for(expression, pending_attrs, issue_meta) ++ issues}

        true ->
          {[], issues}
      end
    end)
    |> elem(1)
    |> Enum.reverse()
  end

  defp body_expressions({:__block__, _meta, expressions}), do: expressions
  defp body_expressions(expression), do: [expression]

  defp attribute?({:@, _meta, [{name, _attribute_meta, _value}]}), do: is_atom(name)
  defp attribute?(_expression), do: false

  defp collect_relevant_attribute({:@, meta, [{:doc, _attribute_meta, _value}]}, pending_attrs) do
    [{:doc, meta[:line]} | pending_attrs]
  end

  defp collect_relevant_attribute({:@, meta, [{:spec, _attribute_meta, _value}]}, pending_attrs) do
    [{:spec, meta[:line]} | pending_attrs]
  end

  defp collect_relevant_attribute(_expression, pending_attrs), do: pending_attrs

  defp public_definition?({kind, _meta, _arguments}), do: kind in @public_definitions
  defp public_definition?(_expression), do: false

  defp maybe_issue_for(definition, pending_attrs, issue_meta) do
    attrs = Enum.reverse(pending_attrs)
    doc_line = last_line_for(attrs, :doc)
    spec_line = first_line_for(attrs, :spec)

    if out_of_order?(doc_line, spec_line) do
      [issue_for(issue_meta, definition_name(definition), doc_line)]
    else
      []
    end
  end

  defp out_of_order?(doc_line, spec_line)
       when is_integer(doc_line) and is_integer(spec_line) and doc_line > spec_line,
       do: true

  defp out_of_order?(_doc_line, _spec_line), do: false

  defp first_line_for(attrs, name) do
    Enum.find_value(attrs, fn
      {^name, line} -> line
      _other -> nil
    end)
  end

  defp last_line_for(attrs, name) do
    attrs
    |> Enum.reverse()
    |> first_line_for(name)
  end

  defp definition_name(
         {kind, _meta, [{:when, _when_meta, [{name, _name_meta, _args} | _guards]} | _rest]}
       )
       when kind in @public_definitions do
    name
  end

  defp definition_name({kind, _meta, [{name, _name_meta, _args} | _rest]})
       when kind in @public_definitions do
    name
  end

  defp issue_for(issue_meta, name, line_no) do
    format_issue(
      issue_meta,
      message:
        "`@doc` should appear before `@spec` so public APIs read as doc, then spec, then function.",
      trigger: Atom.to_string(name),
      line_no: line_no
    )
  end
end