lib/recode/task/specs.ex

defmodule Recode.Task.Specs do
  @shortdoc "Checks for specs."

  @moduledoc """
  Function, macros and callbacks should have typespecs.

  The check only considers whether the specification is present. It doesn't
  perform any actual type checking.

  ## Options

    * `:only` - `:public`, `:visible` or `:all`, defaults to `:all`.
    * `:macros` - when `true`, macros are also checked, defaults to `false`.
  """

  use Recode.Task, category: :readability

  alias Recode.Context
  alias Recode.Issue
  alias Recode.Task.Specs
  alias Rewrite.Source
  alias Sourceror.Zipper

  @impl Recode.Task
  def run(source, opts) do
    include = Keyword.get(opts, :only, :all)
    macros = Keyword.get(opts, :macros, false)

    issues = check_specs(source, {include, macros})

    Source.add_issues(source, issues)
  end

  defp check_specs(source, opts) do
    source
    |> Source.get(:quoted)
    |> Zipper.zip()
    |> Context.traverse({[], nil}, fn zipper, context, acc ->
      check_specs(zipper, context, acc, opts)
    end)
    |> result()
  end

  defp check_specs(zipper, context, {issues, last_def}, opts) do
    case context.definition != last_def do
      true ->
        issues = check_spec(opts, context, issues)
        {zipper, context, {issues, context.definition}}

      false ->
        {zipper, context, {issues, last_def}}
    end
  end

  defp check_spec(_opts, %Context{definition: nil}, issues) do
    issues
  end

  defp check_spec(_opts, %Context{definition: {{:defmacro, :__using__, _args}, _body}}, issues) do
    issues
  end

  defp check_spec({_only, false}, %Context{definition: {{kind, _name, _args}, _body}}, issues)
       when kind in [:defmacro, :defmacrop] do
    issues
  end

  defp check_spec({:all, _macros}, context, issues) do
    case not Context.spec?(context) and not Context.impl?(context) do
      true -> [issue(context) | issues]
      false -> issues
    end
  end

  defp check_spec({only, _macros}, context, issues) do
    case Context.definition?(context, only) and
           not Context.spec?(context) and
           not Context.impl?(context) do
      true -> [issue(context) | issues]
      false -> issues
    end
  end

  defp issue(%Context{definition: {_definition, meta}}) do
    message = "Functions should have a @spec type specification."
    Issue.new(Specs, message, meta)
  end

  defp result({_zipper, {issues, _seen}}), do: issues
end