# Pleroma: A lightweight social networking server # Originally taken from # https://github.com/VeryBigThings/elixir_common/blob/master/lib/vbt/credo/check/consistency/file_location.ex # Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only 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] @special_namespaces [ "controllers", "views", "operations", "channels" ] # `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) cond do expected_file == parsed_path.allowed -> :ok special_namespaces?(parsed_path.allowed) -> original_path = parsed_path.allowed namespace = Enum.find(@special_namespaces, original_path, fn namespace -> String.contains?(original_path, namespace) end) allowed = String.replace(original_path, "/" <> namespace, "") if expected_file == allowed, do: :ok, else: {:error, main_module, expected_file} true -> {:error, main_module, expected_file} end end defp special_namespaces?(path), do: String.contains?(path, @special_namespaces) 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