Skip to content

Commit b33275e

Browse files
authored
Add a format task for converting to and reverting shorthand forms. (#2)
1 parent 7b5d021 commit b33275e

File tree

8 files changed

+344
-3
lines changed

8 files changed

+344
-3
lines changed

.formatter.exs

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# Used by "mix format"
22
[
3-
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
3+
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
4+
locals_without_parens: [test_formatting: 2]
45
]

README.md

+24
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,30 @@ iex> hello
8888
"world"
8989
```
9090

91+
## Converting existing code to use ES6-style maps
92+
93+
`es6_maps` includes a formatting task that will convert your existing map & struct literals into the shorthand style:
94+
95+
```shell
96+
mix es6_maps.format 'lib/**/*.ex' 'test/**/*.exs'
97+
```
98+
99+
The formatting task manipulates the AST, not raw strings, so it's precise and will only change your code by:
100+
101+
1. changing map keys into the shorthand form;
102+
2. reordering map keys so the shorthand form comes first;
103+
3. formatting the results with `mix format`.
104+
105+
See `mix help es6_maps.format` for more options and information.
106+
107+
### Going back to old-style maps
108+
109+
You can revert all of the ES6-style shorthand uses with the `--revert` format flag:
110+
111+
```shell
112+
mix es6_maps.format --revert lib/myapp/myapp.ex
113+
```
114+
91115
## How does it work?
92116

93117
`es6_maps` replaces in runtime the Elixir compiler's `elixir_map` module.

lib/mix/tasks/es6_maps/format.ex

+137
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
defmodule Mix.Tasks.Es6Maps.Format do
2+
@shortdoc "Replaces all map keys with their shorthand form"
3+
@moduledoc """
4+
Replaces all map keys with their shorthand form.
5+
6+
```shell
7+
mix es6_maps.format 'lib/**/*.ex' 'test/**/*.exs'
8+
```
9+
10+
The arguments are expanded with `Path.wildcard(match_dot: true)`.
11+
12+
The task manipulates the AST, not raw strings, so it's precise and will only change your code by:
13+
14+
1. changing map keys into the shorthand form;
15+
2. reordering map keys so the shorthand form comes first;
16+
3. formatting the results with `mix format`.
17+
18+
### Going back to old-style maps
19+
20+
You can revert all of the ES6-style shorthand uses with the `--revert` format flag:
21+
22+
```shell
23+
mix es6_maps.format --revert lib/myapp/myapp.ex
24+
```
25+
26+
### Reordering map keys
27+
28+
When applicable, the formatting will reorder the keys to shorthand them, for example:
29+
30+
```elixir
31+
%{hello: "world", foo: foo, bar: bar} = var
32+
```
33+
34+
will become:
35+
36+
```elixir
37+
%{foo, bar, hello: "world"} = var
38+
```
39+
40+
## Options
41+
* `--revert` - Reverts the transformation.
42+
* `--locals-without-parens` - Specifies a list of locals that should not have parentheses.
43+
The format is `local_name/arity`, where `arity` can be an integer or `*`. This option can
44+
be given multiple times, and/or multiple values can be separated by commas.
45+
"""
46+
47+
use Mix.Task
48+
49+
@switches [revert: :boolean, locals_without_parens: :keep]
50+
51+
@impl Mix.Task
52+
def run(all_args) do
53+
{opts, args} = OptionParser.parse!(all_args, strict: @switches)
54+
55+
locals_without_parens = collect_locals_without_parens(opts)
56+
revert = Keyword.get(opts, :revert, false)
57+
opts = %{locals_without_parens: locals_without_parens, revert: revert}
58+
59+
Enum.each(collect_paths(args), &format_file(&1, opts))
60+
Mix.Tasks.Format.run(args)
61+
end
62+
63+
defp collect_locals_without_parens(opts) do
64+
opts
65+
|> Keyword.get_values(:locals_without_parens)
66+
|> Enum.flat_map(&String.split(&1, ","))
67+
|> Enum.map(fn local_str ->
68+
[fname_str, arity_str] =
69+
case String.split(local_str, "/", parts: 2) do
70+
[fname_str, arity_str] -> [fname_str, arity_str]
71+
_ -> raise ArgumentError, "invalid local: #{local_str}"
72+
end
73+
74+
fname = String.to_atom(fname_str)
75+
arity = if arity_str == "*", do: :*, else: String.to_integer(arity_str)
76+
{fname, arity}
77+
end)
78+
end
79+
80+
defp collect_paths(paths) do
81+
paths |> Enum.flat_map(&Path.wildcard(&1, match_dot: true)) |> Enum.filter(&File.regular?/1)
82+
end
83+
84+
defp format_file(filepath, opts) do
85+
{quoted, comments} =
86+
filepath
87+
|> File.read!()
88+
|> Code.string_to_quoted_with_comments!(
89+
emit_warnings: false,
90+
literal_encoder: &{:ok, {:__block__, &2, [&1]}},
91+
token_metadata: true,
92+
unescape: false,
93+
file: filepath
94+
)
95+
96+
quoted
97+
|> Macro.postwalk(&format_map(&1, opts))
98+
|> Code.quoted_to_algebra(
99+
comments: comments,
100+
escape: false,
101+
locals_without_parens: opts.locals_without_parens
102+
)
103+
|> Inspect.Algebra.format(:infinity)
104+
|> then(&File.write!(filepath, &1))
105+
end
106+
107+
defp format_map({:%{}, meta, [{:|, pipemeta, [lhs, elements]}]}, opts) do
108+
{_, _, mapped_elements} = format_map({:%{}, pipemeta, elements}, opts)
109+
{:%{}, meta, [{:|, pipemeta, [lhs, mapped_elements]}]}
110+
end
111+
112+
defp format_map({:%{}, meta, elements}, %{revert: true}) do
113+
{:%{}, meta,
114+
Enum.map(elements, fn
115+
{key, _meta, context} = var when is_atom(context) -> {key, var}
116+
elem -> elem
117+
end)}
118+
end
119+
120+
defp format_map({:%{}, meta, elements}, _opts) do
121+
{vars, key_vals} =
122+
Enum.reduce(elements, {[], []}, fn
123+
{{:__block__, _, [key]}, {key, _, ctx} = var}, {vars, key_vals} when is_atom(ctx) ->
124+
{[var | vars], key_vals}
125+
126+
{_, _, ctx} = var, {vars, key_vals} when is_atom(ctx) ->
127+
{[var | vars], key_vals}
128+
129+
key_val, {vars, key_vals} ->
130+
{vars, [key_val | key_vals]}
131+
end)
132+
133+
{:%{}, meta, Enum.reverse(key_vals ++ vars)}
134+
end
135+
136+
defp format_map(node, _opts), do: node
137+
end

test/es6_maps_test/mix.exs

+6-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ defmodule Es6MapsTest.MixProject do
66
app: :es6_maps_test,
77
version: "0.1.0",
88
elixir: "~> 1.16",
9+
elixirc_paths: elixirc_paths(Mix.env()),
910
compilers: [:es6_maps | Mix.compilers()],
1011
start_permanent: Mix.env() == :prod,
1112
deps: deps()
@@ -20,7 +21,11 @@ defmodule Es6MapsTest.MixProject do
2021

2122
defp deps do
2223
[
23-
{:es6_maps, path: "../..", runtime: false}
24+
{:es6_maps, path: "../..", runtime: false},
25+
{:briefly, "~> 0.5.0", only: :test}
2426
]
2527
end
28+
29+
defp elixirc_paths(:test), do: ["lib", "test/support"]
30+
defp elixirc_paths(_), do: ["lib"]
2631
end

test/es6_maps_test/mix.lock

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
%{
2+
"briefly": {:hex, :briefly, "0.5.1", "ee10d48da7f79ed2aebdc3e536d5f9a0c3e36ff76c0ad0d4254653a152b13a8a", [:mix], [], "hexpm", "bd684aa92ad8b7b4e0d92c31200993c4bc1469fc68cd6d5f15144041bd15cb57"},
3+
}

test/es6_maps_test/test/es6_maps_test.exs

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
defmodule Es6MapsTestTest do
1+
defmodule Es6MapsTest.Es6Maps do
22
use ExUnit.Case
33

44
defmodule MyStruct do
+151
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
defmodule Es6MapsTest.Format do
2+
use ExUnit.Case
3+
4+
import Es6MapsTest.Support.FormattingAssertions
5+
6+
describe "map literal" do
7+
test_formatting "has its keys reformatted into shorthands",
8+
original: """
9+
def test(var) do
10+
%{a: a, b: b, c: 1} = var
11+
var
12+
end
13+
""",
14+
formatted: """
15+
def test(var) do
16+
%{a, b, c: 1} = var
17+
var
18+
end
19+
"""
20+
21+
test_formatting "has its keys moved to the front when reformatting to shorthand",
22+
original: """
23+
def test(var) do
24+
%{a: 1, b: 2, c: c, d: d} = var
25+
var
26+
end
27+
""",
28+
formatted: """
29+
def test(var) do
30+
%{c, d, a: 1, b: 2} = var
31+
var
32+
end
33+
""",
34+
reverted: """
35+
def test(var) do
36+
%{c: c, d: d, a: 1, b: 2} = var
37+
var
38+
end
39+
"""
40+
end
41+
42+
describe "map update literal" do
43+
test_formatting "has its keys reformatted into shorthands",
44+
original: """
45+
def test(var) do
46+
%{var | a: a, b: b, c: 1}
47+
end
48+
""",
49+
formatted: """
50+
def test(var) do
51+
%{var | a, b, c: 1}
52+
end
53+
"""
54+
55+
test_formatting "has its keys moved to the front when reformatting to shorthand",
56+
original: """
57+
def test(var) do
58+
%{var | a: 1, b: 2, c: c, d: d}
59+
end
60+
""",
61+
formatted: """
62+
def test(var) do
63+
%{var | c, d, a: 1, b: 2}
64+
end
65+
""",
66+
reverted: """
67+
def test(var) do
68+
%{var | c: c, d: d, a: 1, b: 2}
69+
end
70+
"""
71+
end
72+
73+
describe "struct literals" do
74+
test_formatting "has its keys reformatted into shorthands",
75+
original: """
76+
def test(var) do
77+
%A.B.StructName{a: a, b: b, c: 1} = var
78+
var
79+
end
80+
""",
81+
formatted: """
82+
def test(var) do
83+
%A.B.StructName{a, b, c: 1} = var
84+
var
85+
end
86+
"""
87+
88+
test_formatting "has its keys moved to the front when reformatting to shorthand",
89+
original: """
90+
def test(var) do
91+
%A.B.StructName{a: 1, b: 2, c: c, d: d} = var
92+
var
93+
end
94+
""",
95+
formatted: """
96+
def test(var) do
97+
%A.B.StructName{c, d, a: 1, b: 2} = var
98+
var
99+
end
100+
""",
101+
reverted: """
102+
def test(var) do
103+
%A.B.StructName{c: c, d: d, a: 1, b: 2} = var
104+
var
105+
end
106+
"""
107+
end
108+
109+
describe "struct update literal" do
110+
test_formatting "has its keys reformatted into shorthands",
111+
original: """
112+
def test(var) do
113+
%A.B.StructName{var | a: a, b: b, c: 1}
114+
end
115+
""",
116+
formatted: """
117+
def test(var) do
118+
%A.B.StructName{var | a, b, c: 1}
119+
end
120+
"""
121+
122+
test_formatting "has its keys moved to the front when reformatting to shorthand",
123+
original: """
124+
def test(var) do
125+
%A.B.StructName{var | a: 1, b: 2, c: c, d: d}
126+
end
127+
""",
128+
formatted: """
129+
def test(var) do
130+
%A.B.StructName{var | c, d, a: 1, b: 2}
131+
end
132+
""",
133+
reverted: """
134+
def test(var) do
135+
%A.B.StructName{var | c: c, d: d, a: 1, b: 2}
136+
end
137+
"""
138+
end
139+
140+
describe "original code" do
141+
test_formatting "heredoc strings newlines are preserved",
142+
original: ~s'''
143+
def test(var) do
144+
"""
145+
this is
146+
my heredoc
147+
"""
148+
end
149+
'''
150+
end
151+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
defmodule Es6MapsTest.Support.FormattingAssertions do
2+
defmacro test_formatting(name, opts) do
3+
original = Keyword.fetch!(opts, :original)
4+
formatted = Keyword.get(opts, :formatted, original)
5+
reverted = Keyword.get(opts, :reverted, original)
6+
7+
quote location: :keep do
8+
test unquote(name) do
9+
{:ok, path} = Briefly.create()
10+
File.write!(path, unquote(original))
11+
12+
Mix.Tasks.Es6Maps.Format.run([path])
13+
assert File.read!(path) == String.trim(unquote(formatted))
14+
15+
Mix.Tasks.Es6Maps.Format.run([path, "--revert"])
16+
assert File.read!(path) == String.trim(unquote(reverted || original))
17+
end
18+
end
19+
end
20+
end

0 commit comments

Comments
 (0)