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