Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 141 additions & 20 deletions lib/mix/lib/mix/tasks/format.ex
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,24 @@ defmodule Mix.Tasks.Format do
If any of the `--check-*` flags are given and a check fails, the formatted
contents won't be written to disk nor printed to stdout.

## .formatter.exs
## `.formatter.exs`

The formatter will read a `.formatter.exs` in the current directory for
formatter configuration. It should return a keyword list with any of the
options supported by `Code.format_string!/2`.

The `.formatter.exs` also supports an `:inputs` field which specifies the
default inputs to be used by this task:
The `.formatter.exs` also supports other options:

[
inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
* `:inputs` (a list of paths and patterns) - specifies the default inputs
to be used by this task. For example, `["mix.exs", "{config,lib,test}/**/*.{ex,exs}"]`.

* `:import_deps` (a list of dependencies as atoms) - specifies a list
of dependencies whose formatter configuration will be imported.
See the "Importing dependencies configuration" section below for more
information.

* `:export` (a keyword list) - specifies formatter configuration to be exported. See the
"Importing dependencies configuration" section below.

## When to format code

Expand All @@ -65,6 +71,43 @@ defmodule Mix.Tasks.Format do
of patterns and files to `mix format`, as showed at the top of this task
documentation. This list can also be set in the `.formatter.exs` under the
`:inputs` key.

## Importing dependencies configuration

This task supports importing formatter configuration from dependencies.

A dependency that wants to export formatter configuration needs to have a `.formatter.exs` file
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

strictly speaking, for Hex dependencies the .formatter.exs file needs to be in the package and by default it won't be [1]. I think we could add it by default, or make a note here, or hope maintainers will remember to set it. WDYT?

[1] https://github.com/hexpm/hex/blob/v0.17.1/lib/mix/tasks/hex.build.ex#L4

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wojtekmach let's update hex?

at the root of the project. In this file, the dependency can export a `:export` option with
configuration to export. For now, only one option is supported under `:export`:
`:export_locals_without_parens` (whose value has the same shape as the value of the
`:locals_without_parens` in `Code.format_string!/2`).

The functions listed under `:locals_without_parens` in the `:export` option of a dependency
can be imported in a project by listing that dependency in the `:import_deps`
option of the formatter configuration file of the project.

For example, consider I have a project `my_app` that depends on `my_dep`.
`my_dep` wants to export some configuration, so `my_dep/.formatter.exs`
would look like this:

# my_dep/.formatter.exs
[
# Regular formatter configuration for my_dep
# ...

export: [
locals_without_parens: [some_dsl_call: 2, some_dsl_call: 3]
]
]

In order to import configuration, `my_app`'s `.formatter.exs` would look like
this:

# my_app/.formatter.exs
[
import_deps: [:my_dep]
]

"""

@switches [
Expand All @@ -74,9 +117,12 @@ defmodule Mix.Tasks.Format do
dry_run: :boolean
]

@deps_manifest "cached_formatter_deps"

def run(args) do
{opts, args} = OptionParser.parse!(args, strict: @switches)
formatter_opts = eval_dot_formatter(opts)
formatter_opts = fetch_deps_opts(formatter_opts)

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

defp eval_dot_formatter(opts) do
case dot_formatter(opts) do
{:ok, dot_formatter} ->
{formatter_opts, _} = Code.eval_file(dot_formatter)

unless Keyword.keyword?(formatter_opts) do
Mix.raise(
"Expected #{inspect(dot_formatter)} to return a keyword list, " <>
"got: #{inspect(formatter_opts)}"
)
end

formatter_opts

:error ->
[]
{:ok, dot_formatter} -> eval_file_with_keyword_list(dot_formatter)
:error -> []
end
end

Expand All @@ -112,6 +146,93 @@ defmodule Mix.Tasks.Format do
end
end

# This function reads exported configuration from the imported dependencies and deals with
# caching the result of reading such configuration in a manifest file.
defp fetch_deps_opts(formatter_opts) do
deps = Keyword.get(formatter_opts, :import_deps, [])

cond do
deps == [] ->
formatter_opts

is_list(deps) ->
# Since we have dependencies listed, we write the manifest even if those dependencies
# don't export anything so that we avoid lookups everytime.
deps_manifest = Path.join(Mix.Project.manifest_path(), @deps_manifest)

dep_parenless_calls =
if deps_dot_formatters_stale?(deps_manifest) do
dep_parenless_calls = eval_deps_opts(deps)
write_deps_manifest(deps_manifest, dep_parenless_calls)
dep_parenless_calls
else
read_deps_manifest(deps_manifest)
end

Keyword.update(
formatter_opts,
:locals_without_parens,
dep_parenless_calls,
&(&1 ++ dep_parenless_calls)
)

true ->
Mix.raise("Expected :import_deps to return a list of dependencies, got: #{inspect(deps)}")
end
end

defp deps_dot_formatters_stale?(deps_manifest) do
Mix.Utils.stale?([".formatter.exs" | Mix.Project.config_files()], [deps_manifest])
end

defp read_deps_manifest(deps_manifest) do
deps_manifest |> File.read!() |> :erlang.binary_to_term()
end

defp write_deps_manifest(deps_manifest, parenless_calls) do
File.mkdir_p!(Path.dirname(deps_manifest))
File.write!(deps_manifest, :erlang.term_to_binary(parenless_calls))
end

defp eval_deps_opts(deps) do
deps_paths = Mix.Project.deps_paths()

for dep <- deps,
dep_path = assert_valid_dep_and_fetch_path(dep, deps_paths),
dep_dot_formatter = Path.join(dep_path, ".formatter.exs"),
File.regular?(dep_dot_formatter),
dep_opts = eval_file_with_keyword_list(dep_dot_formatter),
parenless_call <- dep_opts[:export][:locals_without_parens] || [],
uniq: true,
do: parenless_call
end

defp assert_valid_dep_and_fetch_path(dep, deps_paths) when is_atom(dep) do
case Map.fetch(deps_paths, dep) do
{:ok, path} ->
path

:error ->
Mix.raise(
"Found a dependency in :import_deps that the project doesn't depend on: #{inspect(dep)}"
)
end
end

defp assert_valid_dep_and_fetch_path(dep, _deps_paths) do
Mix.raise("Dependencies in :import_deps should be atoms, got: #{inspect(dep)}")
end

defp eval_file_with_keyword_list(path) do
{opts, _} = Code.eval_file(path)

unless Keyword.keyword?(opts) do
Mix.raise("Expected #{inspect(path)} to return a keyword list, got: #{inspect(opts)}")
end

opts
end

defp expand_args([], formatter_opts) do
if inputs = formatter_opts[:inputs] do
expand_files_and_patterns(List.wrap(inputs), ".formatter.exs")
Expand Down
63 changes: 63 additions & 0 deletions lib/mix/test/mix/tasks/format_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ defmodule Mix.Tasks.FormatTest do

import ExUnit.CaptureIO

defmodule FormatWithDepsApp do
def project do
[
app: :format_with_deps,
version: "0.1.0",
deps: [{:my_dep, "0.1.0", path: "deps/my_dep"}]
]
end
end

test "formats the given files", context do
in_tmp context.test, fn ->
File.write!("a.ex", """
Expand Down Expand Up @@ -174,6 +184,59 @@ defmodule Mix.Tasks.FormatTest do
end
end

test "can read exported configuration from dependencies", context do
Mix.Project.push(__MODULE__.FormatWithDepsApp)

in_tmp context.test, fn ->
File.write!(".formatter.exs", """
[import_deps: [:my_dep]]
""")

File.write!("a.ex", """
my_fun :foo, :bar
""")

File.mkdir_p!("deps/my_dep/")

File.write!("deps/my_dep/.formatter.exs", """
[export: [locals_without_parens: [my_fun: 2]]]
""")

Mix.Tasks.Format.run(["a.ex"])

assert File.read!("a.ex") == """
my_fun :foo, :bar
"""

manifest_path = Path.join(Mix.Project.manifest_path(), "cached_formatter_deps")

assert File.regular?(manifest_path)

# Let's check that the manifest gets updated if it's stale.
File.touch!(manifest_path, {{1970, 1, 1}, {0, 0, 0}})

Mix.Tasks.Format.run(["a.ex"])

assert File.stat!(manifest_path).mtime > {{1970, 1, 1}, {0, 0, 0}}
end
end

test "validates dependencies in :import_deps", context do
Mix.Project.push(__MODULE__.FormatWithDepsApp)

in_tmp context.test, fn ->
File.write!(".formatter.exs", """
[import_deps: [:nonexistent_dep]]
""")

message =
"Found a dependency in :import_deps that the project doesn't " <>
"depend on: :nonexistent_dep"

assert_raise Mix.Error, message, fn -> Mix.Tasks.Format.run([]) end
end
end

test "raises on invalid arguments", context do
in_tmp context.test, fn ->
assert_raise Mix.Error, ~r"Expected one or more files\/patterns to be given", fn ->
Expand Down