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