From 9b94cabdf2dc0a79a19ac618a86dc18ec40fd840 Mon Sep 17 00:00:00 2001 From: Konrad Zemek Date: Sun, 28 Apr 2024 16:07:15 +0200 Subject: [PATCH] Add a format task. --- .formatter.exs | 3 +- README.md | 24 +++ lib/mix/tasks/es6_maps/format.ex | 137 ++++++++++++++++ test/es6_maps_test/mix.exs | 7 +- test/es6_maps_test/mix.lock | 3 + test/es6_maps_test/test/es6_maps_test.exs | 2 +- test/es6_maps_test/test/format_test.exs | 151 ++++++++++++++++++ .../test/support/formatting_assertions.ex | 20 +++ 8 files changed, 344 insertions(+), 3 deletions(-) create mode 100644 lib/mix/tasks/es6_maps/format.ex create mode 100644 test/es6_maps_test/test/format_test.exs create mode 100644 test/es6_maps_test/test/support/formatting_assertions.ex diff --git a/.formatter.exs b/.formatter.exs index d2cda26..dd82b8c 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -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] ] diff --git a/README.md b/README.md index 962b20b..630643a 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/lib/mix/tasks/es6_maps/format.ex b/lib/mix/tasks/es6_maps/format.ex new file mode 100644 index 0000000..7976792 --- /dev/null +++ b/lib/mix/tasks/es6_maps/format.ex @@ -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 diff --git a/test/es6_maps_test/mix.exs b/test/es6_maps_test/mix.exs index d35fb55..12ce461 100644 --- a/test/es6_maps_test/mix.exs +++ b/test/es6_maps_test/mix.exs @@ -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() @@ -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 diff --git a/test/es6_maps_test/mix.lock b/test/es6_maps_test/mix.lock index e69de29..5a4b413 100644 --- a/test/es6_maps_test/mix.lock +++ b/test/es6_maps_test/mix.lock @@ -0,0 +1,3 @@ +%{ + "briefly": {:hex, :briefly, "0.5.1", "ee10d48da7f79ed2aebdc3e536d5f9a0c3e36ff76c0ad0d4254653a152b13a8a", [:mix], [], "hexpm", "bd684aa92ad8b7b4e0d92c31200993c4bc1469fc68cd6d5f15144041bd15cb57"}, +} diff --git a/test/es6_maps_test/test/es6_maps_test.exs b/test/es6_maps_test/test/es6_maps_test.exs index 58022cb..5499db1 100644 --- a/test/es6_maps_test/test/es6_maps_test.exs +++ b/test/es6_maps_test/test/es6_maps_test.exs @@ -1,4 +1,4 @@ -defmodule Es6MapsTestTest do +defmodule Es6MapsTest.Es6Maps do use ExUnit.Case defmodule MyStruct do diff --git a/test/es6_maps_test/test/format_test.exs b/test/es6_maps_test/test/format_test.exs new file mode 100644 index 0000000..b3ea13b --- /dev/null +++ b/test/es6_maps_test/test/format_test.exs @@ -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 diff --git a/test/es6_maps_test/test/support/formatting_assertions.ex b/test/es6_maps_test/test/support/formatting_assertions.ex new file mode 100644 index 0000000..e7402ce --- /dev/null +++ b/test/es6_maps_test/test/support/formatting_assertions.ex @@ -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