Skip to content

Commit

Permalink
Add a format task.
Browse files Browse the repository at this point in the history
  • Loading branch information
kzemek committed Apr 28, 2024
1 parent 7b5d021 commit 9b94cab
Show file tree
Hide file tree
Showing 8 changed files with 344 additions and 3 deletions.
3 changes: 2 additions & 1 deletion .formatter.exs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
locals_without_parens: [test_formatting: 2]
]
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,30 @@ iex> hello
"world"
```

## Converting existing code to use ES6-style maps

`es6_maps` includes a formatting task that will convert your existing map & struct literals into the shorthand style:

```shell
mix es6_maps.format 'lib/**/*.ex' 'test/**/*.exs'
```

The formatting task manipulates the AST, not raw strings, so it's precise and will only change your code by:

1. changing map keys into the shorthand form;
2. reordering map keys so the shorthand form comes first;
3. formatting the results with `mix format`.

See `mix help es6_maps.format` for more options and information.

### Going back to old-style maps

You can revert all of the ES6-style shorthand uses with the `--revert` format flag:

```shell
mix es6_maps.format --revert lib/myapp/myapp.ex
```

## How does it work?

`es6_maps` replaces in runtime the Elixir compiler's `elixir_map` module.
Expand Down
137 changes: 137 additions & 0 deletions lib/mix/tasks/es6_maps/format.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
defmodule Mix.Tasks.Es6Maps.Format do
@shortdoc "Replaces all map keys with their shorthand form"
@moduledoc """
Replaces all map keys with their shorthand form.
```shell
mix es6_maps.format 'lib/**/*.ex' 'test/**/*.exs'
```
The arguments are expanded with `Path.wildcard(match_dot: true)`.
The task manipulates the AST, not raw strings, so it's precise and will only change your code by:
1. changing map keys into the shorthand form;
2. reordering map keys so the shorthand form comes first;
3. formatting the results with `mix format`.
### Going back to old-style maps
You can revert all of the ES6-style shorthand uses with the `--revert` format flag:
```shell
mix es6_maps.format --revert lib/myapp/myapp.ex
```
### Reordering map keys
When applicable, the formatting will reorder the keys to shorthand them, for example:
```elixir
%{hello: "world", foo: foo, bar: bar} = var
```
will become:
```elixir
%{foo, bar, hello: "world"} = var
```
## Options
* `--revert` - Reverts the transformation.
* `--locals-without-parens` - Specifies a list of locals that should not have parentheses.
The format is `local_name/arity`, where `arity` can be an integer or `*`. This option can
be given multiple times, and/or multiple values can be separated by commas.
"""

use Mix.Task

@switches [revert: :boolean, locals_without_parens: :keep]

@impl Mix.Task
def run(all_args) do
{opts, args} = OptionParser.parse!(all_args, strict: @switches)

locals_without_parens = collect_locals_without_parens(opts)
revert = Keyword.get(opts, :revert, false)
opts = %{locals_without_parens: locals_without_parens, revert: revert}

Enum.each(collect_paths(args), &format_file(&1, opts))
Mix.Tasks.Format.run(args)
end

defp collect_locals_without_parens(opts) do
opts
|> Keyword.get_values(:locals_without_parens)
|> Enum.flat_map(&String.split(&1, ","))
|> Enum.map(fn local_str ->
[fname_str, arity_str] =
case String.split(local_str, "/", parts: 2) do
[fname_str, arity_str] -> [fname_str, arity_str]
_ -> raise ArgumentError, "invalid local: #{local_str}"
end

fname = String.to_atom(fname_str)
arity = if arity_str == "*", do: :*, else: String.to_integer(arity_str)
{fname, arity}
end)
end

defp collect_paths(paths) do
paths |> Enum.flat_map(&Path.wildcard(&1, match_dot: true)) |> Enum.filter(&File.regular?/1)
end

defp format_file(filepath, opts) do
{quoted, comments} =
filepath
|> File.read!()
|> Code.string_to_quoted_with_comments!(
emit_warnings: false,
literal_encoder: &{:ok, {:__block__, &2, [&1]}},
token_metadata: true,
unescape: false,
file: filepath
)

quoted
|> Macro.postwalk(&format_map(&1, opts))
|> Code.quoted_to_algebra(
comments: comments,
escape: false,
locals_without_parens: opts.locals_without_parens
)
|> Inspect.Algebra.format(:infinity)
|> then(&File.write!(filepath, &1))
end

defp format_map({:%{}, meta, [{:|, pipemeta, [lhs, elements]}]}, opts) do
{_, _, mapped_elements} = format_map({:%{}, pipemeta, elements}, opts)
{:%{}, meta, [{:|, pipemeta, [lhs, mapped_elements]}]}
end

defp format_map({:%{}, meta, elements}, %{revert: true}) do
{:%{}, meta,
Enum.map(elements, fn
{key, _meta, context} = var when is_atom(context) -> {key, var}
elem -> elem
end)}
end

defp format_map({:%{}, meta, elements}, _opts) do
{vars, key_vals} =
Enum.reduce(elements, {[], []}, fn
{{:__block__, _, [key]}, {key, _, ctx} = var}, {vars, key_vals} when is_atom(ctx) ->
{[var | vars], key_vals}

{_, _, ctx} = var, {vars, key_vals} when is_atom(ctx) ->
{[var | vars], key_vals}

key_val, {vars, key_vals} ->
{vars, [key_val | key_vals]}
end)

{:%{}, meta, Enum.reverse(key_vals ++ vars)}
end

defp format_map(node, _opts), do: node
end
7 changes: 6 additions & 1 deletion test/es6_maps_test/mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ defmodule Es6MapsTest.MixProject do
app: :es6_maps_test,
version: "0.1.0",
elixir: "~> 1.16",
elixirc_paths: elixirc_paths(Mix.env()),
compilers: [:es6_maps | Mix.compilers()],
start_permanent: Mix.env() == :prod,
deps: deps()
Expand All @@ -20,7 +21,11 @@ defmodule Es6MapsTest.MixProject do

defp deps do
[
{:es6_maps, path: "../..", runtime: false}
{:es6_maps, path: "../..", runtime: false},
{:briefly, "~> 0.5.0", only: :test}
]
end

defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
end
3 changes: 3 additions & 0 deletions test/es6_maps_test/mix.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
%{
"briefly": {:hex, :briefly, "0.5.1", "ee10d48da7f79ed2aebdc3e536d5f9a0c3e36ff76c0ad0d4254653a152b13a8a", [:mix], [], "hexpm", "bd684aa92ad8b7b4e0d92c31200993c4bc1469fc68cd6d5f15144041bd15cb57"},
}
2 changes: 1 addition & 1 deletion test/es6_maps_test/test/es6_maps_test.exs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
defmodule Es6MapsTestTest do
defmodule Es6MapsTest.Es6Maps do
use ExUnit.Case

defmodule MyStruct do
Expand Down
151 changes: 151 additions & 0 deletions test/es6_maps_test/test/format_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
defmodule Es6MapsTest.Format do
use ExUnit.Case

import Es6MapsTest.Support.FormattingAssertions

describe "map literal" do
test_formatting "has its keys reformatted into shorthands",
original: """
def test(var) do
%{a: a, b: b, c: 1} = var
var
end
""",
formatted: """
def test(var) do
%{a, b, c: 1} = var
var
end
"""

test_formatting "has its keys moved to the front when reformatting to shorthand",
original: """
def test(var) do
%{a: 1, b: 2, c: c, d: d} = var
var
end
""",
formatted: """
def test(var) do
%{c, d, a: 1, b: 2} = var
var
end
""",
reverted: """
def test(var) do
%{c: c, d: d, a: 1, b: 2} = var
var
end
"""
end

describe "map update literal" do
test_formatting "has its keys reformatted into shorthands",
original: """
def test(var) do
%{var | a: a, b: b, c: 1}
end
""",
formatted: """
def test(var) do
%{var | a, b, c: 1}
end
"""

test_formatting "has its keys moved to the front when reformatting to shorthand",
original: """
def test(var) do
%{var | a: 1, b: 2, c: c, d: d}
end
""",
formatted: """
def test(var) do
%{var | c, d, a: 1, b: 2}
end
""",
reverted: """
def test(var) do
%{var | c: c, d: d, a: 1, b: 2}
end
"""
end

describe "struct literals" do
test_formatting "has its keys reformatted into shorthands",
original: """
def test(var) do
%A.B.StructName{a: a, b: b, c: 1} = var
var
end
""",
formatted: """
def test(var) do
%A.B.StructName{a, b, c: 1} = var
var
end
"""

test_formatting "has its keys moved to the front when reformatting to shorthand",
original: """
def test(var) do
%A.B.StructName{a: 1, b: 2, c: c, d: d} = var
var
end
""",
formatted: """
def test(var) do
%A.B.StructName{c, d, a: 1, b: 2} = var
var
end
""",
reverted: """
def test(var) do
%A.B.StructName{c: c, d: d, a: 1, b: 2} = var
var
end
"""
end

describe "struct update literal" do
test_formatting "has its keys reformatted into shorthands",
original: """
def test(var) do
%A.B.StructName{var | a: a, b: b, c: 1}
end
""",
formatted: """
def test(var) do
%A.B.StructName{var | a, b, c: 1}
end
"""

test_formatting "has its keys moved to the front when reformatting to shorthand",
original: """
def test(var) do
%A.B.StructName{var | a: 1, b: 2, c: c, d: d}
end
""",
formatted: """
def test(var) do
%A.B.StructName{var | c, d, a: 1, b: 2}
end
""",
reverted: """
def test(var) do
%A.B.StructName{var | c: c, d: d, a: 1, b: 2}
end
"""
end

describe "original code" do
test_formatting "heredoc strings newlines are preserved",
original: ~s'''
def test(var) do
"""
this is
my heredoc
"""
end
'''
end
end
20 changes: 20 additions & 0 deletions test/es6_maps_test/test/support/formatting_assertions.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
defmodule Es6MapsTest.Support.FormattingAssertions do
defmacro test_formatting(name, opts) do
original = Keyword.fetch!(opts, :original)
formatted = Keyword.get(opts, :formatted, original)
reverted = Keyword.get(opts, :reverted, original)

quote location: :keep do
test unquote(name) do
{:ok, path} = Briefly.create()
File.write!(path, unquote(original))

Mix.Tasks.Es6Maps.Format.run([path])
assert File.read!(path) == String.trim(unquote(formatted))

Mix.Tasks.Es6Maps.Format.run([path, "--revert"])
assert File.read!(path) == String.trim(unquote(reverted || original))
end
end
end
end

0 comments on commit 9b94cab

Please sign in to comment.