Skip to content

Commit cb9bf1b

Browse files
authored
Allow subdirectories in .formatter.exs (#7398)
1 parent d94a9cc commit cb9bf1b

File tree

3 files changed

+329
-90
lines changed

3 files changed

+329
-90
lines changed

lib/mix/lib/mix/tasks/format.ex

Lines changed: 148 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ defmodule Mix.Tasks.Format do
2222
* `:inputs` (a list of paths and patterns) - specifies the default inputs
2323
to be used by this task. For example, `["mix.exs", "{config,lib,test}/**/*.{ex,exs}"]`.
2424
25+
* `:subdirectories` (a list of paths and patterns) - specifies subdirectories
26+
that have their own formatting rules. Each subdirectory should have a
27+
`.formatter.exs` that configures how entries in that subdirectory should be
28+
formatted as. Configuration between `.formatter.exs` are not shared nor
29+
inherited. If a `.formatter.exs` lists "lib/app" as a subdirectory, the rules
30+
in `.formatter.exs` won't be available in `lib/app/.formatter.exs`.
31+
Note that the parent `.formatter.exs` must not specify files inside the "lib/app"
32+
subdirectory in its `:inputs` configuration. If this happens, the behaviour of
33+
which formatter configuration will be picked is unspecified.
34+
2535
* `:import_deps` (a list of dependencies as atoms) - specifies a list
2636
of dependencies whose formatter configuration will be imported.
2737
When specified, the formatter should run in the same directory as
@@ -115,16 +125,19 @@ defmodule Mix.Tasks.Format do
115125
dry_run: :boolean
116126
]
117127

118-
@deps_manifest "cached_formatter_deps"
128+
@manifest "cached_dot_formatter"
129+
@manifest_vsn 1
119130

120131
def run(args) do
121132
{opts, args} = OptionParser.parse!(args, strict: @switches)
122133
{dot_formatter, formatter_opts} = eval_dot_formatter(opts)
123-
formatter_opts = fetch_deps_opts(dot_formatter, formatter_opts)
134+
135+
{formatter_opts_and_subs, _sources} =
136+
eval_deps_and_subdirectories(dot_formatter, [], formatter_opts, [dot_formatter])
124137

125138
args
126-
|> expand_args(formatter_opts)
127-
|> Task.async_stream(&format_file(&1, opts, formatter_opts), ordered: false, timeout: 30000)
139+
|> expand_args(dot_formatter, formatter_opts_and_subs)
140+
|> Task.async_stream(&format_file(&1, opts), ordered: false, timeout: 30000)
128141
|> Enum.reduce({[], [], []}, &collect_status/2)
129142
|> check!()
130143
end
@@ -142,71 +155,110 @@ defmodule Mix.Tasks.Format do
142155
end
143156
end
144157

145-
# This function reads exported configuration from the imported dependencies and deals with
146-
# caching the result of reading such configuration in a manifest file.
147-
defp fetch_deps_opts(dot_formatter, formatter_opts) do
158+
# This function reads exported configuration from the imported
159+
# dependencies and subdirectories and deals with caching the result
160+
# of reading such configuration in a manifest file.
161+
defp eval_deps_and_subdirectories(dot_formatter, prefix, formatter_opts, sources) do
148162
deps = Keyword.get(formatter_opts, :import_deps, [])
163+
subs = Keyword.get(formatter_opts, :subdirectories, [])
149164

150-
cond do
151-
deps == [] ->
152-
formatter_opts
153-
154-
is_list(deps) ->
155-
# Since we have dependencies listed, we write the manifest even if those
156-
# dependencies don't export anything so that we avoid lookups everytime.
157-
deps_manifest = Path.join(Mix.Project.manifest_path(), @deps_manifest)
158-
dep_parenless_calls = maybe_cache_eval_deps_opts(dot_formatter, deps_manifest, deps)
159-
160-
Keyword.update(
161-
formatter_opts,
162-
:locals_without_parens,
163-
dep_parenless_calls,
164-
&(&1 ++ dep_parenless_calls)
165-
)
165+
if not is_list(deps) do
166+
Mix.raise("Expected :import_deps to return a list of dependencies, got: #{inspect(deps)}")
167+
end
166168

167-
true ->
168-
Mix.raise("Expected :import_deps to return a list of dependencies, got: #{inspect(deps)}")
169+
if not is_list(subs) do
170+
Mix.raise("Expected :subdirectories to return a list of directories, got: #{inspect(subs)}")
171+
end
172+
173+
if deps == [] and subs == [] do
174+
{{formatter_opts, []}, sources}
175+
else
176+
manifest = Path.join(Mix.Project.manifest_path(), @manifest)
177+
178+
maybe_cache_in_manifest(dot_formatter, manifest, fn ->
179+
{subdirectories, sources} = eval_subs_opts(subs, prefix, sources)
180+
{{eval_deps_opts(formatter_opts, deps), subdirectories}, sources}
181+
end)
169182
end
170183
end
171184

172-
defp maybe_cache_eval_deps_opts(dot_formatter, deps_manifest, deps) do
185+
defp maybe_cache_in_manifest(dot_formatter, manifest, fun) do
173186
cond do
174-
dot_formatter != ".formatter.exs" ->
175-
eval_deps_opts(deps)
176-
177-
deps_dot_formatters_stale?(dot_formatter, deps_manifest) ->
178-
write_deps_manifest(deps_manifest, eval_deps_opts(deps))
187+
is_nil(Mix.Project.get()) or dot_formatter != ".formatter.exs" -> fun.()
188+
entry = read_manifest(manifest) -> entry
189+
true -> write_manifest!(manifest, fun.())
190+
end
191+
end
179192

180-
true ->
181-
read_deps_manifest(deps_manifest)
193+
def read_manifest(manifest) do
194+
with {:ok, binary} <- File.read(manifest),
195+
{:ok, {@manifest_vsn, entry, sources}} <- safe_binary_to_term(binary),
196+
expanded_sources = Enum.flat_map(sources, &Path.wildcard(&1, match_dot: true)),
197+
false <- Mix.Utils.stale?(Mix.Project.config_files() ++ expanded_sources, [manifest]) do
198+
{entry, sources}
199+
else
200+
_ -> nil
182201
end
183202
end
184203

185-
defp deps_dot_formatters_stale?(dot_formatter, deps_manifest) do
186-
Mix.Utils.stale?([dot_formatter | Mix.Project.config_files()], [deps_manifest])
204+
defp safe_binary_to_term(binary) do
205+
{:ok, :erlang.binary_to_term(binary)}
206+
rescue
207+
_ -> :error
187208
end
188209

189-
defp read_deps_manifest(deps_manifest) do
190-
deps_manifest |> File.read!() |> :erlang.binary_to_term()
210+
defp write_manifest!(manifest, {entry, sources}) do
211+
File.mkdir_p!(Path.dirname(manifest))
212+
File.write!(manifest, :erlang.term_to_binary({@manifest_vsn, entry, sources}))
213+
{entry, sources}
191214
end
192215

193-
defp write_deps_manifest(deps_manifest, parenless_calls) do
194-
File.mkdir_p!(Path.dirname(deps_manifest))
195-
File.write!(deps_manifest, :erlang.term_to_binary(parenless_calls))
196-
parenless_calls
216+
defp eval_deps_opts(formatter_opts, []) do
217+
formatter_opts
197218
end
198219

199-
defp eval_deps_opts(deps) do
220+
defp eval_deps_opts(formatter_opts, deps) do
200221
deps_paths = Mix.Project.deps_paths()
201222

202-
for dep <- deps,
203-
dep_path = assert_valid_dep_and_fetch_path(dep, deps_paths),
204-
dep_dot_formatter = Path.join(dep_path, ".formatter.exs"),
205-
File.regular?(dep_dot_formatter),
206-
dep_opts = eval_file_with_keyword_list(dep_dot_formatter),
207-
parenless_call <- dep_opts[:export][:locals_without_parens] || [],
208-
uniq: true,
209-
do: parenless_call
223+
parenless_calls =
224+
for dep <- deps,
225+
dep_path = assert_valid_dep_and_fetch_path(dep, deps_paths),
226+
dep_dot_formatter = Path.join(dep_path, ".formatter.exs"),
227+
File.regular?(dep_dot_formatter),
228+
dep_opts = eval_file_with_keyword_list(dep_dot_formatter),
229+
parenless_call <- dep_opts[:export][:locals_without_parens] || [],
230+
uniq: true,
231+
do: parenless_call
232+
233+
Keyword.update(
234+
formatter_opts,
235+
:locals_without_parens,
236+
parenless_calls,
237+
&(&1 ++ parenless_calls)
238+
)
239+
end
240+
241+
defp eval_subs_opts(subs, prefix, sources) do
242+
{subs, sources} =
243+
Enum.flat_map_reduce(subs, sources, fn sub, sources ->
244+
prefix = Path.join(prefix ++ [sub])
245+
{Path.wildcard(prefix), [Path.join(prefix, ".formatter.exs") | sources]}
246+
end)
247+
248+
Enum.flat_map_reduce(subs, sources, fn sub, sources ->
249+
sub_formatter = Path.join(sub, ".formatter.exs")
250+
251+
if File.exists?(sub_formatter) do
252+
formatter_opts = eval_file_with_keyword_list(sub_formatter)
253+
254+
{formatter_opts_and_subs, sources} =
255+
eval_deps_and_subdirectories(:in_memory, [sub], formatter_opts, sources)
256+
257+
{[{sub, formatter_opts_and_subs}], sources}
258+
else
259+
{[], sources}
260+
end
261+
end)
210262
end
211263

212264
defp assert_valid_dep_and_fetch_path(dep, deps_paths) when is_atom(dep) do
@@ -243,22 +295,20 @@ defmodule Mix.Tasks.Format do
243295
opts
244296
end
245297

246-
defp expand_args([], formatter_opts) do
247-
if inputs = formatter_opts[:inputs] do
248-
expand_files_and_patterns(List.wrap(inputs), ".formatter.exs")
249-
else
298+
defp expand_args([], dot_formatter, formatter_opts_and_subs) do
299+
if no_entries_in_formatter_opts?(formatter_opts_and_subs) do
250300
Mix.raise(
251301
"Expected one or more files/patterns to be given to mix format " <>
252-
"or for a .formatter.exs to exist with an :inputs key"
302+
"or for a .formatter.exs to exist with an :inputs or :subdirectories key"
253303
)
254304
end
255-
end
256305

257-
defp expand_args(files_and_patterns, _formatter_opts) do
258-
expand_files_and_patterns(files_and_patterns, "command line")
306+
dot_formatter
307+
|> expand_dot_inputs([], formatter_opts_and_subs, %{})
308+
|> Enum.uniq()
259309
end
260310

261-
defp expand_files_and_patterns(files_and_patterns, context) do
311+
defp expand_args(files_and_patterns, _dot_formatter, formatter_opts_and_subs) do
262312
files =
263313
for file_or_pattern <- files_and_patterns,
264314
file <- stdin_or_wildcard(file_or_pattern),
@@ -267,12 +317,48 @@ defmodule Mix.Tasks.Format do
267317

268318
if files == [] do
269319
Mix.raise(
270-
"Could not find a file to format. The files/patterns from #{context} " <>
320+
"Could not find a file to format. The files/patterns given to command line " <>
271321
"did not point to any existing file. Got: #{inspect(files_and_patterns)}"
272322
)
273323
end
274324

275-
files
325+
for file <- files do
326+
if file == :stdin do
327+
{file, []}
328+
else
329+
split = file |> Path.relative_to_cwd() |> Path.split()
330+
{file, find_formatter_opts_for_file(split, formatter_opts_and_subs)}
331+
end
332+
end
333+
end
334+
335+
defp expand_dot_inputs(dot_formatter, prefix, {formatter_opts, subs}, acc) do
336+
if no_entries_in_formatter_opts?({formatter_opts, subs}) do
337+
Mix.raise("Expected :inputs or :subdirectories key in #{dot_formatter}")
338+
end
339+
340+
map =
341+
for input <- List.wrap(formatter_opts[:inputs]),
342+
file <- Path.wildcard(Path.join(prefix ++ [input])),
343+
do: {file, formatter_opts},
344+
into: %{}
345+
346+
Enum.reduce(subs, Map.merge(acc, map), fn {sub, formatter_opts_and_subs}, acc ->
347+
sub_formatter = Path.join(sub, ".formatter.exs")
348+
expand_dot_inputs(sub_formatter, [sub], formatter_opts_and_subs, acc)
349+
end)
350+
end
351+
352+
defp find_formatter_opts_for_file(split, {formatter_opts, subs}) do
353+
Enum.find_value(subs, formatter_opts, fn {sub, formatter_opts_and_subs} ->
354+
if List.starts_with?(split, Path.split(sub)) do
355+
find_formatter_opts_for_file(split, formatter_opts_and_subs)
356+
end
357+
end)
358+
end
359+
360+
defp no_entries_in_formatter_opts?({formatter_opts, subs}) do
361+
is_nil(formatter_opts[:inputs]) and subs == []
276362
end
277363

278364
defp stdin_or_wildcard("-"), do: [:stdin]
@@ -286,7 +372,7 @@ defmodule Mix.Tasks.Format do
286372
{File.read!(file), file: file}
287373
end
288374

289-
defp format_file(file, task_opts, formatter_opts) do
375+
defp format_file({file, formatter_opts}, task_opts) do
290376
{input, extra_opts} = read_file(file)
291377
output = IO.iodata_to_binary([Code.format_string!(input, extra_opts ++ formatter_opts), ?\n])
292378

lib/mix/lib/mix/tasks/new.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,8 @@ defmodule Mix.Tasks.New do
255255
embed_template(:formatter_umbrella, """
256256
# Used by "mix format"
257257
[
258-
inputs: ["mix.exs", "apps/*/mix.exs", "apps/*/{config,lib,test}/**/*.{ex,exs}"]
258+
inputs: ["mix.exs", "config/*.exs"],
259+
subdirectories: ["apps/*"]
259260
]
260261
""")
261262

0 commit comments

Comments
 (0)