First
[anni] / test / credo / check / consistency / file_location.ex
1 # Pleroma: A lightweight social networking server
2 # Originally taken from
3 # https://github.com/VeryBigThings/elixir_common/blob/master/lib/vbt/credo/check/consistency/file_location.ex
4 # Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
5 # SPDX-License-Identifier: AGPL-3.0-only
6
7 defmodule Credo.Check.Consistency.FileLocation do
8   @moduledoc false
9
10   # credo:disable-for-this-file Credo.Check.Readability.Specs
11
12   @checkdoc """
13   File location should follow the namespace hierarchy of the module it defines.
14
15   Examples:
16
17       - `lib/my_system.ex` should define the `MySystem` module
18       - `lib/my_system/accounts.ex` should define the `MySystem.Accounts` module
19   """
20   @explanation [warning: @checkdoc]
21
22   @special_namespaces [
23     "controllers",
24     "views",
25     "operations",
26     "channels"
27   ]
28
29   # `use Credo.Check` required that module attributes are already defined, so we need
30   # to place these attributes
31   # before use/alias expressions.
32   # credo:disable-for-next-line VBT.Credo.Check.Consistency.ModuleLayout
33   use Credo.Check, category: :warning, base_priority: :high
34
35   alias Credo.Code
36
37   def run(source_file, params \\ []) do
38     case verify(source_file, params) do
39       :ok ->
40         []
41
42       {:error, module, expected_file} ->
43         error(IssueMeta.for(source_file, params), module, expected_file)
44     end
45   end
46
47   defp verify(source_file, params) do
48     source_file.filename
49     |> Path.relative_to_cwd()
50     |> verify(Code.ast(source_file), params)
51   end
52
53   @doc false
54   def verify(relative_path, ast, params) do
55     if verify_path?(relative_path, params),
56       do: ast |> main_module() |> verify_module(relative_path, params),
57       else: :ok
58   end
59
60   defp verify_path?(relative_path, params) do
61     case Path.split(relative_path) do
62       ["lib" | _] -> not exclude?(relative_path, params)
63       ["test", "support" | _] -> false
64       ["test", "test_helper.exs"] -> false
65       ["test" | _] -> not exclude?(relative_path, params)
66       _ -> false
67     end
68   end
69
70   defp exclude?(relative_path, params) do
71     params
72     |> Keyword.get(:exclude, [])
73     |> Enum.any?(&String.starts_with?(relative_path, &1))
74   end
75
76   defp main_module(ast) do
77     {_ast, modules} = Macro.prewalk(ast, [], &traverse/2)
78     Enum.at(modules, -1)
79   end
80
81   defp traverse({:defmodule, _meta, args}, modules) do
82     [{:__aliases__, _, name_parts}, _module_body] = args
83     {args, [Module.concat(name_parts) | modules]}
84   end
85
86   defp traverse(ast, state), do: {ast, state}
87
88   # empty file - shouldn't really happen, but we'll let it through
89   defp verify_module(nil, _relative_path, _params), do: :ok
90
91   defp verify_module(main_module, relative_path, params) do
92     parsed_path = parsed_path(relative_path, params)
93
94     expected_file =
95       expected_file_base(parsed_path.root, main_module) <>
96         Path.extname(parsed_path.allowed)
97
98     cond do
99       expected_file == parsed_path.allowed ->
100         :ok
101
102       special_namespaces?(parsed_path.allowed) ->
103         original_path = parsed_path.allowed
104
105         namespace =
106           Enum.find(@special_namespaces, original_path, fn namespace ->
107             String.contains?(original_path, namespace)
108           end)
109
110         allowed = String.replace(original_path, "/" <> namespace, "")
111
112         if expected_file == allowed,
113           do: :ok,
114           else: {:error, main_module, expected_file}
115
116       true ->
117         {:error, main_module, expected_file}
118     end
119   end
120
121   defp special_namespaces?(path), do: String.contains?(path, @special_namespaces)
122
123   defp parsed_path(relative_path, params) do
124     parts = Path.split(relative_path)
125
126     allowed =
127       Keyword.get(params, :ignore_folder_namespace, %{})
128       |> Stream.flat_map(fn {root, folders} -> Enum.map(folders, &Path.join([root, &1])) end)
129       |> Stream.map(&Path.split/1)
130       |> Enum.find(&List.starts_with?(parts, &1))
131       |> case do
132         nil ->
133           relative_path
134
135         ignore_parts ->
136           Stream.drop(ignore_parts, -1)
137           |> Enum.concat(Stream.drop(parts, length(ignore_parts)))
138           |> Path.join()
139       end
140
141     %{root: hd(parts), allowed: allowed}
142   end
143
144   defp expected_file_base(root_folder, module) do
145     {parent_namespace, module_name} = module |> Module.split() |> Enum.split(-1)
146
147     relative_path =
148       if parent_namespace == [],
149         do: "",
150         else: parent_namespace |> Module.concat() |> Macro.underscore()
151
152     file_name = module_name |> Module.concat() |> Macro.underscore()
153
154     Path.join([root_folder, relative_path, file_name])
155   end
156
157   defp error(issue_meta, module, expected_file) do
158     format_issue(issue_meta,
159       message:
160         "Mismatch between file name and main module #{inspect(module)}. " <>
161           "Expected file path to be #{expected_file}. " <>
162           "Either move the file or rename the module.",
163       line_no: 1
164     )
165   end
166 end