Skip to content

Commit 91ededc

Browse files
whatyouhidejosevalim
authored andcommitted
Allow "mix format" to read exported configuration from dependencies (#7108)
1 parent 7c9f9e5 commit 91ededc

File tree

2 files changed

+204
-20
lines changed

2 files changed

+204
-20
lines changed

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

Lines changed: 141 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -36,18 +36,24 @@ defmodule Mix.Tasks.Format do
3636
If any of the `--check-*` flags are given and a check fails, the formatted
3737
contents won't be written to disk nor printed to stdout.
3838
39-
## .formatter.exs
39+
## `.formatter.exs`
4040
4141
The formatter will read a `.formatter.exs` in the current directory for
4242
formatter configuration. It should return a keyword list with any of the
4343
options supported by `Code.format_string!/2`.
4444
45-
The `.formatter.exs` also supports an `:inputs` field which specifies the
46-
default inputs to be used by this task:
45+
The `.formatter.exs` also supports other options:
4746
48-
[
49-
inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"]
50-
]
47+
* `:inputs` (a list of paths and patterns) - specifies the default inputs
48+
to be used by this task. For example, `["mix.exs", "{config,lib,test}/**/*.{ex,exs}"]`.
49+
50+
* `:import_deps` (a list of dependencies as atoms) - specifies a list
51+
of dependencies whose formatter configuration will be imported.
52+
See the "Importing dependencies configuration" section below for more
53+
information.
54+
55+
* `:export` (a keyword list) - specifies formatter configuration to be exported. See the
56+
"Importing dependencies configuration" section below.
5157
5258
## When to format code
5359
@@ -65,6 +71,43 @@ defmodule Mix.Tasks.Format do
6571
of patterns and files to `mix format`, as showed at the top of this task
6672
documentation. This list can also be set in the `.formatter.exs` under the
6773
`:inputs` key.
74+
75+
## Importing dependencies configuration
76+
77+
This task supports importing formatter configuration from dependencies.
78+
79+
A dependency that wants to export formatter configuration needs to have a `.formatter.exs` file
80+
at the root of the project. In this file, the dependency can export a `:export` option with
81+
configuration to export. For now, only one option is supported under `:export`:
82+
`:export_locals_without_parens` (whose value has the same shape as the value of the
83+
`:locals_without_parens` in `Code.format_string!/2`).
84+
85+
The functions listed under `:locals_without_parens` in the `:export` option of a dependency
86+
can be imported in a project by listing that dependency in the `:import_deps`
87+
option of the formatter configuration file of the project.
88+
89+
For example, consider I have a project `my_app` that depends on `my_dep`.
90+
`my_dep` wants to export some configuration, so `my_dep/.formatter.exs`
91+
would look like this:
92+
93+
# my_dep/.formatter.exs
94+
[
95+
# Regular formatter configuration for my_dep
96+
# ...
97+
98+
export: [
99+
locals_without_parens: [some_dsl_call: 2, some_dsl_call: 3]
100+
]
101+
]
102+
103+
In order to import configuration, `my_app`'s `.formatter.exs` would look like
104+
this:
105+
106+
# my_app/.formatter.exs
107+
[
108+
import_deps: [:my_dep]
109+
]
110+
68111
"""
69112

70113
@switches [
@@ -74,9 +117,12 @@ defmodule Mix.Tasks.Format do
74117
dry_run: :boolean
75118
]
76119

120+
@deps_manifest "cached_formatter_deps"
121+
77122
def run(args) do
78123
{opts, args} = OptionParser.parse!(args, strict: @switches)
79124
formatter_opts = eval_dot_formatter(opts)
125+
formatter_opts = fetch_deps_opts(formatter_opts)
80126

81127
args
82128
|> expand_args(formatter_opts)
@@ -87,20 +133,8 @@ defmodule Mix.Tasks.Format do
87133

88134
defp eval_dot_formatter(opts) do
89135
case dot_formatter(opts) do
90-
{:ok, dot_formatter} ->
91-
{formatter_opts, _} = Code.eval_file(dot_formatter)
92-
93-
unless Keyword.keyword?(formatter_opts) do
94-
Mix.raise(
95-
"Expected #{inspect(dot_formatter)} to return a keyword list, " <>
96-
"got: #{inspect(formatter_opts)}"
97-
)
98-
end
99-
100-
formatter_opts
101-
102-
:error ->
103-
[]
136+
{:ok, dot_formatter} -> eval_file_with_keyword_list(dot_formatter)
137+
:error -> []
104138
end
105139
end
106140

@@ -112,6 +146,93 @@ defmodule Mix.Tasks.Format do
112146
end
113147
end
114148

149+
# This function reads exported configuration from the imported dependencies and deals with
150+
# caching the result of reading such configuration in a manifest file.
151+
defp fetch_deps_opts(formatter_opts) do
152+
deps = Keyword.get(formatter_opts, :import_deps, [])
153+
154+
cond do
155+
deps == [] ->
156+
formatter_opts
157+
158+
is_list(deps) ->
159+
# Since we have dependencies listed, we write the manifest even if those dependencies
160+
# don't export anything so that we avoid lookups everytime.
161+
deps_manifest = Path.join(Mix.Project.manifest_path(), @deps_manifest)
162+
163+
dep_parenless_calls =
164+
if deps_dot_formatters_stale?(deps_manifest) do
165+
dep_parenless_calls = eval_deps_opts(deps)
166+
write_deps_manifest(deps_manifest, dep_parenless_calls)
167+
dep_parenless_calls
168+
else
169+
read_deps_manifest(deps_manifest)
170+
end
171+
172+
Keyword.update(
173+
formatter_opts,
174+
:locals_without_parens,
175+
dep_parenless_calls,
176+
&(&1 ++ dep_parenless_calls)
177+
)
178+
179+
true ->
180+
Mix.raise("Expected :import_deps to return a list of dependencies, got: #{inspect(deps)}")
181+
end
182+
end
183+
184+
defp deps_dot_formatters_stale?(deps_manifest) do
185+
Mix.Utils.stale?([".formatter.exs" | Mix.Project.config_files()], [deps_manifest])
186+
end
187+
188+
defp read_deps_manifest(deps_manifest) do
189+
deps_manifest |> File.read!() |> :erlang.binary_to_term()
190+
end
191+
192+
defp write_deps_manifest(deps_manifest, parenless_calls) do
193+
File.mkdir_p!(Path.dirname(deps_manifest))
194+
File.write!(deps_manifest, :erlang.term_to_binary(parenless_calls))
195+
end
196+
197+
defp eval_deps_opts(deps) do
198+
deps_paths = Mix.Project.deps_paths()
199+
200+
for dep <- deps,
201+
dep_path = assert_valid_dep_and_fetch_path(dep, deps_paths),
202+
dep_dot_formatter = Path.join(dep_path, ".formatter.exs"),
203+
File.regular?(dep_dot_formatter),
204+
dep_opts = eval_file_with_keyword_list(dep_dot_formatter),
205+
parenless_call <- dep_opts[:export][:locals_without_parens] || [],
206+
uniq: true,
207+
do: parenless_call
208+
end
209+
210+
defp assert_valid_dep_and_fetch_path(dep, deps_paths) when is_atom(dep) do
211+
case Map.fetch(deps_paths, dep) do
212+
{:ok, path} ->
213+
path
214+
215+
:error ->
216+
Mix.raise(
217+
"Found a dependency in :import_deps that the project doesn't depend on: #{inspect(dep)}"
218+
)
219+
end
220+
end
221+
222+
defp assert_valid_dep_and_fetch_path(dep, _deps_paths) do
223+
Mix.raise("Dependencies in :import_deps should be atoms, got: #{inspect(dep)}")
224+
end
225+
226+
defp eval_file_with_keyword_list(path) do
227+
{opts, _} = Code.eval_file(path)
228+
229+
unless Keyword.keyword?(opts) do
230+
Mix.raise("Expected #{inspect(path)} to return a keyword list, got: #{inspect(opts)}")
231+
end
232+
233+
opts
234+
end
235+
115236
defp expand_args([], formatter_opts) do
116237
if inputs = formatter_opts[:inputs] do
117238
expand_files_and_patterns(List.wrap(inputs), ".formatter.exs")

lib/mix/test/mix/tasks/format_test.exs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ defmodule Mix.Tasks.FormatTest do
55

66
import ExUnit.CaptureIO
77

8+
defmodule FormatWithDepsApp do
9+
def project do
10+
[
11+
app: :format_with_deps,
12+
version: "0.1.0",
13+
deps: [{:my_dep, "0.1.0", path: "deps/my_dep"}]
14+
]
15+
end
16+
end
17+
818
test "formats the given files", context do
919
in_tmp context.test, fn ->
1020
File.write!("a.ex", """
@@ -174,6 +184,59 @@ defmodule Mix.Tasks.FormatTest do
174184
end
175185
end
176186

187+
test "can read exported configuration from dependencies", context do
188+
Mix.Project.push(__MODULE__.FormatWithDepsApp)
189+
190+
in_tmp context.test, fn ->
191+
File.write!(".formatter.exs", """
192+
[import_deps: [:my_dep]]
193+
""")
194+
195+
File.write!("a.ex", """
196+
my_fun :foo, :bar
197+
""")
198+
199+
File.mkdir_p!("deps/my_dep/")
200+
201+
File.write!("deps/my_dep/.formatter.exs", """
202+
[export: [locals_without_parens: [my_fun: 2]]]
203+
""")
204+
205+
Mix.Tasks.Format.run(["a.ex"])
206+
207+
assert File.read!("a.ex") == """
208+
my_fun :foo, :bar
209+
"""
210+
211+
manifest_path = Path.join(Mix.Project.manifest_path(), "cached_formatter_deps")
212+
213+
assert File.regular?(manifest_path)
214+
215+
# Let's check that the manifest gets updated if it's stale.
216+
File.touch!(manifest_path, {{1970, 1, 1}, {0, 0, 0}})
217+
218+
Mix.Tasks.Format.run(["a.ex"])
219+
220+
assert File.stat!(manifest_path).mtime > {{1970, 1, 1}, {0, 0, 0}}
221+
end
222+
end
223+
224+
test "validates dependencies in :import_deps", context do
225+
Mix.Project.push(__MODULE__.FormatWithDepsApp)
226+
227+
in_tmp context.test, fn ->
228+
File.write!(".formatter.exs", """
229+
[import_deps: [:nonexistent_dep]]
230+
""")
231+
232+
message =
233+
"Found a dependency in :import_deps that the project doesn't " <>
234+
"depend on: :nonexistent_dep"
235+
236+
assert_raise Mix.Error, message, fn -> Mix.Tasks.Format.run([]) end
237+
end
238+
end
239+
177240
test "raises on invalid arguments", context do
178241
in_tmp context.test, fn ->
179242
assert_raise Mix.Error, ~r"Expected one or more files\/patterns to be given", fn ->

0 commit comments

Comments
 (0)