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