file locations consistency
authorAlexander Strizhakov <alex.strizhakov@gmail.com>
Sat, 20 Jun 2020 15:37:44 +0000 (18:37 +0300)
committerAlexander Strizhakov <alex.strizhakov@gmail.com>
Tue, 13 Oct 2020 13:33:24 +0000 (16:33 +0300)
.credo.exs
lib/credo/check/consistency/file_location.ex [new file with mode: 0644]

index 46d45d015716267a6ee5c224afe9c2db9d34681d..83e34a2b4de5b4fe3e4b0c36dc9cc6e17ee84000 100644 (file)
@@ -25,7 +25,7 @@
       #
       # If you create your own checks, you must specify the source files for
       # them here, so they can be loaded by Credo before running the analysis.
-      requires: [],
+      requires: ["./lib/credo/check/consistency/file_location.ex"],
       #
       # Credo automatically checks for updates, like e.g. Hex does.
       # You can disable this behaviour below:
@@ -71,7 +71,6 @@
         # set this value to 0 (zero).
         {Credo.Check.Design.TagTODO, exit_status: 0},
         {Credo.Check.Design.TagFIXME, exit_status: 0},
-
         {Credo.Check.Readability.FunctionNames},
         {Credo.Check.Readability.LargeNumbers},
         {Credo.Check.Readability.MaxLineLength, priority: :low, max_length: 100},
@@ -91,7 +90,6 @@
         {Credo.Check.Readability.VariableNames},
         {Credo.Check.Readability.Semicolons},
         {Credo.Check.Readability.SpaceAfterCommas},
-
         {Credo.Check.Refactor.DoubleBooleanNegation},
         {Credo.Check.Refactor.CondStatements},
         {Credo.Check.Refactor.CyclomaticComplexity},
         {Credo.Check.Refactor.Nesting},
         {Credo.Check.Refactor.PipeChainStart},
         {Credo.Check.Refactor.UnlessWithElse},
-
         {Credo.Check.Warning.BoolOperationOnSameValues},
         {Credo.Check.Warning.IExPry},
         {Credo.Check.Warning.IoInspect},
 
         # Custom checks can be created using `mix credo.gen.check`.
         #
+        {Credo.Check.Consistency.FileLocation}
       ]
     }
   ]
diff --git a/lib/credo/check/consistency/file_location.ex b/lib/credo/check/consistency/file_location.ex
new file mode 100644 (file)
index 0000000..5ef17b8
--- /dev/null
@@ -0,0 +1,132 @@
+defmodule Credo.Check.Consistency.FileLocation do
+  @moduledoc false
+
+  # credo:disable-for-this-file Credo.Check.Readability.Specs
+
+  @checkdoc """
+  File location should follow the namespace hierarchy of the module it defines.
+
+  Examples:
+
+      - `lib/my_system.ex` should define the `MySystem` module
+      - `lib/my_system/accounts.ex` should define the `MySystem.Accounts` module
+  """
+  @explanation [warning: @checkdoc]
+
+  # `use Credo.Check` required that module attributes are already defined, so we need to place these attributes
+  # before use/alias expressions.
+  # credo:disable-for-next-line VBT.Credo.Check.Consistency.ModuleLayout
+  use Credo.Check, category: :warning, base_priority: :high
+
+  alias Credo.Code
+
+  def run(source_file, params \\ []) do
+    case verify(source_file, params) do
+      :ok ->
+        []
+
+      {:error, module, expected_file} ->
+        error(IssueMeta.for(source_file, params), module, expected_file)
+    end
+  end
+
+  defp verify(source_file, params) do
+    source_file.filename
+    |> Path.relative_to_cwd()
+    |> verify(Code.ast(source_file), params)
+  end
+
+  @doc false
+  def verify(relative_path, ast, params) do
+    if verify_path?(relative_path, params),
+      do: ast |> main_module() |> verify_module(relative_path, params),
+      else: :ok
+  end
+
+  defp verify_path?(relative_path, params) do
+    case Path.split(relative_path) do
+      ["lib" | _] -> not exclude?(relative_path, params)
+      ["test", "support" | _] -> false
+      ["test", "test_helper.exs"] -> false
+      ["test" | _] -> not exclude?(relative_path, params)
+      _ -> false
+    end
+  end
+
+  defp exclude?(relative_path, params) do
+    params
+    |> Keyword.get(:exclude, [])
+    |> Enum.any?(&String.starts_with?(relative_path, &1))
+  end
+
+  defp main_module(ast) do
+    {_ast, modules} = Macro.prewalk(ast, [], &traverse/2)
+    Enum.at(modules, -1)
+  end
+
+  defp traverse({:defmodule, _meta, args}, modules) do
+    [{:__aliases__, _, name_parts}, _module_body] = args
+    {args, [Module.concat(name_parts) | modules]}
+  end
+
+  defp traverse(ast, state), do: {ast, state}
+
+  # empty file - shouldn't really happen, but we'll let it through
+  defp verify_module(nil, _relative_path, _params), do: :ok
+
+  defp verify_module(main_module, relative_path, params) do
+    parsed_path = parsed_path(relative_path, params)
+
+    expected_file =
+      expected_file_base(parsed_path.root, main_module) <>
+        Path.extname(parsed_path.allowed)
+
+    if expected_file == parsed_path.allowed,
+      do: :ok,
+      else: {:error, main_module, expected_file}
+  end
+
+  defp parsed_path(relative_path, params) do
+    parts = Path.split(relative_path)
+
+    allowed =
+      Keyword.get(params, :ignore_folder_namespace, %{})
+      |> Stream.flat_map(fn {root, folders} -> Enum.map(folders, &Path.join([root, &1])) end)
+      |> Stream.map(&Path.split/1)
+      |> Enum.find(&List.starts_with?(parts, &1))
+      |> case do
+        nil ->
+          relative_path
+
+        ignore_parts ->
+          Stream.drop(ignore_parts, -1)
+          |> Enum.concat(Stream.drop(parts, length(ignore_parts)))
+          |> Path.join()
+      end
+
+    %{root: hd(parts), allowed: allowed}
+  end
+
+  defp expected_file_base(root_folder, module) do
+    {parent_namespace, module_name} = module |> Module.split() |> Enum.split(-1)
+
+    relative_path =
+      if parent_namespace == [],
+        do: "",
+        else: parent_namespace |> Module.concat() |> Macro.underscore()
+
+    file_name = module_name |> Module.concat() |> Macro.underscore()
+
+    Path.join([root_folder, relative_path, file_name])
+  end
+
+  defp error(issue_meta, module, expected_file) do
+    format_issue(issue_meta,
+      message:
+        "Mismatch between file name and main module #{inspect(module)}. " <>
+          "Expected file path to be #{expected_file}. " <>
+          "Either move the file or rename the module.",
+      line_no: 1
+    )
+  end
+end