Skip to main content

lib/mix/tasks/def_layout.skipped.ex

defmodule Mix.Tasks.DefLayout.Skipped do
  @shortdoc "Lists the modules DefLayout declines to lay out"

  @moduledoc """
  Lists the module bodies DefLayout skips - the ones it leaves in source order
  rather than laying out.

      mix def_layout.skipped [PATHS...]

  With no arguments it reads the `:inputs` from your `.formatter.exs`, matching
  the files `mix format` would format. Pass file paths or globs to scan those
  instead.

  Each skipped module is listed with its location and the reason it was
  skipped, one per line:

      path:line: Module.Label - reason

  A module is reported when DefLayout can't safely move its definitions - an
  expression or nested module interleaved among the functions, non-adjacent
  clauses of the same function, an unrecognized construct above the first def,
  a used macro below the first def, expansion-time code that calls one of the
  module's functions, or a keyword-form module body. Reporting is per module,
  including nested ones: an inner module can be skipped while its outer lays
  out, and the reverse. Already-laid-out modules are not reported. The reason
  echoes the README's "What gets skipped" vocabulary, so it indexes the section
  that explains why each case is skipped.
  """

  use Mix.Task

  alias DefLayout.Scan

  @module_kinds [:defmodule, :defimpl]

  @impl Mix.Task
  def run(argv) do
    {_opts, paths, _} = OptionParser.parse(argv, strict: [])

    paths
    |> files()
    |> Enum.flat_map(fn path -> skipped_modules(File.read!(path), path) end)
    |> report()
  end

  defp report([]) do
    Mix.shell().info("No skipped modules.")
  end

  defp report(skips) do
    for {path, line, label, reason} <- skips do
      Mix.shell().info("#{path}:#{line}: #{label} - #{reason}")
    end
  end

  defp files([]), do: inputs(File.cwd!())

  defp files(paths) do
    paths
    |> Enum.flat_map(fn path -> Path.wildcard(path, match_dot: true) end)
    |> Enum.uniq()
    |> Enum.filter(&elixir_file?/1)
  end

  # Mirrors `mix format`'s no-argument file selection (`Mix.Tasks.Format`'s
  # `expand_dot_inputs`/`eval_subs_opts`): the top-level `.formatter.exs`
  # `:inputs` minus `:excludes`, then each `:subdirectories` entry's own
  # `.formatter.exs` resolved relative to that subdirectory, deduped across
  # overlapping claims. Only the file SET is mimicked - never plugin loading or
  # formatting - so a no-argument run scans exactly what would be formatted.
  @doc false
  def inputs(cwd) do
    cwd
    |> dot_formatter_files(Path.join(cwd, ".formatter.exs"))
    |> Enum.uniq()
    |> Enum.filter(&elixir_file?/1)
    |> Enum.map(&Path.relative_to(&1, cwd))
  end

  defp dot_formatter_files(cwd, dot_formatter) do
    opts = eval_formatter(dot_formatter)

    excluded =
      opts
      |> Keyword.get(:excludes)
      |> List.wrap()
      |> Enum.flat_map(&wildcard(&1, cwd))
      |> MapSet.new()

    inputs =
      for input <- List.wrap(opts[:inputs]),
          file <- wildcard(input, cwd),
          file not in excluded,
          do: file

    inputs ++ subdirectory_files(opts, cwd)
  end

  defp eval_formatter(path) do
    if File.regular?(path) do
      {opts, _} = Code.eval_file(path)
      opts
    else
      []
    end
  end

  defp wildcard(glob, cwd) do
    glob
    |> Path.expand(cwd)
    |> Path.wildcard(match_dot: true)
  end

  defp subdirectory_files(opts, cwd) do
    # `mix format` expands `:subdirectories` with a plain `Path.wildcard` - no
    # `match_dot:` - so dot directories are not matched here (unlike `:inputs`).
    for sub_glob <- List.wrap(opts[:subdirectories]),
        sub <-
          sub_glob
          |> Path.expand(cwd)
          |> Path.wildcard(),
        sub_formatter = Path.join(sub, ".formatter.exs"),
        File.exists?(sub_formatter),
        file <- dot_formatter_files(sub, sub_formatter),
        do: file
  end

  defp elixir_file?(path) do
    Path.extname(path) in ~w(.ex .exs) and File.regular?(path)
  end

  @doc """
  Returns a `{path, line, label, reason}` tuple for every skipped module body
  in `source`, in source order, descending through nested modules. `line` is
  the module node's source line and `reason` is the explanatory phrase.
  """
  @spec skipped_modules(String.t(), Path.t()) :: [{Path.t(), pos_integer, String.t(), String.t()}]
  def skipped_modules(source, path) do
    case Code.string_to_quoted(source, token_metadata: true) do
      {:ok, ast} ->
        source_lines = String.split(source, "\n")

        ast
        |> module_skips(source_lines, "")
        |> Enum.map(fn {line, label, reason} -> {path, line, label, phrase(reason)} end)

      {:error, _} ->
        []
    end
  end

  # Mirrors DefLayout's own module walk: each `defmodule`/`defimpl` is checked,
  # then its body is descended into regardless of whether it bailed, so a
  # facade's nested modules are still reported. The name prefix accumulates so
  # nested labels read fully-qualified.
  defp module_skips(block, source_lines, prefix) do
    block
    |> block_exprs()
    |> Enum.flat_map(&one_module(&1, source_lines, prefix))
  end

  defp block_exprs({:__block__, _, exprs}) when is_list(exprs), do: exprs
  defp block_exprs(expr), do: [expr]

  defp one_module({kind, meta, args}, source_lines, prefix) when kind in @module_kinds and is_list(args) do
    label = label(kind, args, prefix)
    line = Keyword.fetch!(meta, :line)
    do_line = meta[:do][:line]

    case List.last(args) do
      [{:do, {:__block__, _, exprs} = body}] when is_list(exprs) and is_integer(do_line) ->
        own_skip(exprs, do_line, source_lines, line, label) ++
          module_skips(body, source_lines, label)

      [_ | _] = opts ->
        # Any other options list is keyword-form (no `do/end` block, so no
        # integer `:do` line). When its `:do` is a block of expressions -
        # whether the list is just `[do: ...]` or also carries `for:`/other
        # options, as the keyword form of `defimpl` does - DefLayout can't
        # splice it, so it's a skip (case b). A non-block body (a literal or a
        # single statement) has nothing to lay out. Either way, descend in case
        # the body holds a nested module.
        case List.keyfind(opts, :do, 0) do
          {:do, {:__block__, _, exprs} = body} when is_list(exprs) ->
            [{line, label, :keyword_form} | module_skips(body, source_lines, label)]

          {:do, body} ->
            module_skips(body, source_lines, label)

          nil ->
            []
        end

      _ ->
        []
    end
  end

  defp one_module(_expr, _source_lines, _prefix), do: []

  defp label(:defimpl, [name | rest], prefix) do
    base = qualify(prefix, alias_name(name))

    rest
    |> List.first()
    |> for_target()
    |> case do
      nil -> base
      target -> "#{base} (for: #{target})"
    end
  end

  defp label(_kind, [name | _], prefix), do: qualify(prefix, alias_name(name))

  defp qualify("", name), do: name
  defp qualify(prefix, name), do: "#{prefix}.#{name}"

  defp for_target(opts) when is_list(opts) do
    case List.keyfind(opts, :for, 0) do
      {:for, target} -> alias_name(target)
      nil -> nil
    end
  end

  defp for_target(_), do: nil

  defp alias_name({:__aliases__, _, segments}), do: Enum.map_join(segments, ".", &to_string/1)
  defp alias_name({:__MODULE__, _, _}), do: "__MODULE__"
  defp alias_name(other), do: Macro.to_string(other)

  # Case (a): the scan bails, carrying which bail fired. A successful scan -
  # whether it reorders or the module is already conformant (case c) - is not a
  # skip. A `:no_defs` body has no def-family to lay out, so it's vacuously
  # conformant too, not a decline: never reported.
  defp own_skip(exprs, do_line, source_lines, line, label) do
    case Scan.def_groups(exprs, do_line, source_lines) do
      {:ok, _groups} -> []
      {:error, :no_defs} -> []
      {:error, reason} -> [{line, label, reason}]
    end
  end

  # The scan's reason atoms and the keyword-form case map to phrases that echo
  # the README's "What gets skipped" vocabulary verbatim.
  defp phrase(:interleaved_expression), do: "an expression interleaved among the functions"
  defp phrase(:nested_module), do: "a nested module among the functions"
  defp phrase(:non_adjacent_clauses), do: "non-adjacent clauses of the same function"
  defp phrase(:unrecognized_header), do: "an unrecognized construct above the first def"
  defp phrase(:used_macro_below_def), do: "a used macro below the first def"

  defp phrase(:expansion_calls_function), do: "expansion-time code calls one of the module's functions"

  defp phrase(:keyword_form), do: "keyword-form module body"
end