diff --git a/README.md b/README.md index 02b7fa1..60d7dc7 100644 --- a/README.md +++ b/README.md @@ -17,17 +17,17 @@ This feature will be immediately familiar to JavaScript and Rust developers, and ### Is there any runtime overhead? -No; the shorthand map keys compile down to exactly the same bytecode as the "old-style" maps. +No; the shorthand map keys compile down to exactly the same bytecode as the "vanilla-style" maps. ## Installation The package can be installed by adding `es6_maps` to your list of dependencies and compilers in `mix.exs`: ```elixir +# mix.exs + def project do [ - app: :testme, - version: "0.1.0", compilers: [:es6_maps | Mix.compilers()], deps: deps() ] @@ -94,28 +94,54 @@ iex> hello ## 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: +`es6_maps` includes a formatting plugin that will convert your existing map and struct literals into the shorthand style. +Add the plugin to `.formatter.exs`, then call `mix format` to reformat your code: -```shell -mix es6_maps.format 'lib/**/*.ex' 'test/**/*.exs' +```elixir +# .formatter.exs +[ + plugins: [Es6Maps.Formatter], + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] ``` -The formatting task manipulates the AST, not raw strings, so it's precise and will only change your code by: +The plugin 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`. +3. formatting the results like `mix format` would. + +### Reverting to the vanilla-style maps -See `mix help es6_maps.format` for more options and information. +The formatting plugin can also be used to revert all of the ES6-style map shorthand uses back to the "vanilla" style. +Set the `es6_maps: [map_style: :vanilla]` option in `.formatter.exs`, then call `mix format` to reformat your code: -### Going back to old-style maps +```elixir +# .formatter.exs +[ + plugins: [Es6Maps.Formatter], + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], + es6_maps: [map_style: :vanilla] +] +``` -You can revert all of the ES6-style shorthand uses with the `--revert` format flag: +### Formatting pragmas -```shell -mix es6_maps.format --revert lib/myapp/myapp.ex +The plugin supports pragmas in the comments to control the formatting. +The pragma must be in the form `# es6_maps: [map_style: :es6]` and can be placed anywhere in the file. +The `map_style` option can be set to `:es6` to convert to shorthand form or `:vanilla` to revert to the vanilla-style maps. +The pragma takes effect only on the line following the comment. + +For example in the code below, the first map will be formatted to the shorthand form, while the second map will be left as is: + +```elixir + %{foo, bar: 1} = var + # es6_maps: [map_style: :vanilla] + %{hello: hello, foo: foo, bar: 1} = var ``` +`es6_maps: [map_style: :vanilla]` option in `.formatter.exs` can be combined with `# es6_maps: [map_style: :es6]` comment pragmas. + ## How does it work? `es6_maps` replaces in runtime the Elixir compiler's `elixir_map` module. diff --git a/lib/es6_maps/formatter.ex b/lib/es6_maps/formatter.ex new file mode 100644 index 0000000..6c06d56 --- /dev/null +++ b/lib/es6_maps/formatter.ex @@ -0,0 +1,152 @@ +defmodule Es6Maps.Formatter do + @moduledoc """ + Replaces all map keys with their shorthand form. + + Add the plugin to `.formatter.exs`, then call `mix format` to reformat your code: + + ```elixir + # .formatter.exs + [ + plugins: [Es6Maps.Formatter], + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] + ] + ``` + + The plugin 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 like `mix format` would. + + ### Reverting to the vanilla-style maps + + The formatting plugin can also be used to revert all of the ES6-style map shorthand uses back to the "vanilla" style. + Set the `es6_maps: [map_style: :vanilla]` option in `.formatter.exs`, then call `mix format` to reformat your code: + + ```elixir + # .formatter.exs + [ + plugins: [Es6Maps.Formatter], + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], + es6_maps: [map_style: :vanilla] + ] + ``` + + ### Formatting pragmas + + The plugin supports pragmas in the comments to control the formatting. + The pragma must be in the form `# es6_maps: [map_style: :vanilla]` and can be placed anywhere in the file. + The `map_style` option can be set to `:es6` to convert to shorthand form or `:vanilla` to revert to the vanilla-style maps. + The pragma takes effect only on the line following the comment. + + For example in the code below, the first map will be formatted to the shorthand form, while the second map will be left as is: + + ```elixir + %{foo, bar: 1} = var + # es6_maps: [map_style: :vanilla] + %{hello: hello, foo: foo, bar: 1} = var + ``` + + `es6_maps: [map_style: :vanilla]` option in `.formatter.exs` can be combined with `# es6_maps: [map_style: :es6]` comment pragmas. + + ## Options + * `es6_maps`: + * `map_style` - `:es6` to convert to shorthand form, `:vanilla` to revert to the vanilla-style maps. + * all other options of mix format, such as `line_length`, are supported and passed down to formatting functions. + """ + + @behaviour Mix.Tasks.Format + + @impl Mix.Tasks.Format + def features(_opts), do: [sigils: [], extensions: [".ex", ".exs"]] + + def format(contents), do: format(contents, []) + + @impl Mix.Tasks.Format + def format(contents, opts) do + line_length = Keyword.get(opts, :line_length, 98) + + {quoted, comments} = + Code.string_to_quoted_with_comments!( + contents, + Keyword.merge( + [ + unescape: false, + literal_encoder: &{:ok, {:__block__, &2, [&1]}}, + token_metadata: true, + emit_warnings: false + ], + opts + ) + ) + + pragmas = comments_to_pragmas(comments) + + quoted + |> Macro.postwalk(&format_map(&1, pragmas, opts)) + |> Code.Formatter.to_algebra(Keyword.merge([comments: comments], opts)) + |> Inspect.Algebra.format(line_length) + |> case do + [] -> "" + text -> IO.iodata_to_binary([text, ?\n]) + end + end + + defp comments_to_pragmas(comments) do + comments + |> Enum.filter(&String.starts_with?(&1.text, "# es6_maps: ")) + |> Map.new(fn comment -> + {settings, _} = + comment.text + |> String.replace_prefix("# ", "[") + |> String.replace_suffix("", "]") + |> Code.eval_string() + + {comment.line + 1, settings} + end) + end + + defp format_map({:%{}, meta, [{:|, pipemeta, [lhs, elements]}]}, pragmas, opts) do + {_, _, mapped_elements} = format_map({:%{}, meta, elements}, pragmas, opts) + {:%{}, meta, [{:|, pipemeta, [lhs, mapped_elements]}]} + end + + defp format_map({:%{}, meta, _elements} = map, pragmas, opts) do + opts = Config.Reader.merge(opts, Map.get(pragmas, meta[:line], [])) + + case Kernel.get_in(opts, [:es6_maps, :map_style]) || :es6 do + :es6 -> format_map_es6(map) + :vanilla -> format_map_vanilla(map) + other -> raise ArgumentError, "invalid map_style: #{inspect(other)}" + end + end + + defp format_map(node, _pragmas, _opts), do: node + + defp format_map_vanilla({:%{}, meta, elements}) do + {:%{}, meta, + Enum.map(elements, fn + {key, meta, context} = var when is_atom(context) -> + {{:__block__, [format: :keyword] ++ meta, [key]}, var} + + elem -> + elem + end)} + end + + defp format_map_es6({:%{}, meta, elements}) 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 +end diff --git a/lib/mix/tasks/es6_maps/format.ex b/lib/mix/tasks/es6_maps/format.ex deleted file mode 100644 index 7cbf8a0..0000000 --- a/lib/mix/tasks/es6_maps/format.ex +++ /dev/null @@ -1,137 +0,0 @@ -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(98) - |> 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 66c481e..ad5414f 100644 --- a/test/es6_maps_test/mix.exs +++ b/test/es6_maps_test/mix.exs @@ -21,8 +21,7 @@ defmodule Es6MapsTest.MixProject do defp deps do [ - {:es6_maps, path: "../..", runtime: false}, - {:briefly, "~> 0.5.0", only: :test} + {:es6_maps, path: "../..", runtime: false} ] end diff --git a/test/es6_maps_test/mix.lock b/test/es6_maps_test/mix.lock deleted file mode 100644 index 5a4b413..0000000 --- a/test/es6_maps_test/mix.lock +++ /dev/null @@ -1,3 +0,0 @@ -%{ - "briefly": {:hex, :briefly, "0.5.1", "ee10d48da7f79ed2aebdc3e536d5f9a0c3e36ff76c0ad0d4254653a152b13a8a", [:mix], [], "hexpm", "bd684aa92ad8b7b4e0d92c31200993c4bc1469fc68cd6d5f15144041bd15cb57"}, -} diff --git a/test/es6_maps_test/test/format_test.exs b/test/es6_maps_test/test/format_test.exs index bd6d472..e71a331 100644 --- a/test/es6_maps_test/test/format_test.exs +++ b/test/es6_maps_test/test/format_test.exs @@ -37,6 +37,56 @@ defmodule Es6MapsTest.Format do var end """ + + test_formatting "is not reformatted when inline-comment sets the map-style to :vanilla", + original: """ + def test(var) do + %{a: a, b: b} = var + # es6_maps: [map_style: :vanilla] + %{a: a, b: b} = var + %{a: a, b: b} = var + var + end + """, + formatted: """ + def test(var) do + %{a, b} = var + # es6_maps: [map_style: :vanilla] + %{a: a, b: b} = var + %{a, b} = var + var + end + """ + + test_formatting "is reformatted when inline-comment sets the map-style to :es6 but default style is :vanilla", + opts: [es6_maps: [map_style: :vanilla]], + original: """ + def test(var) do + %{a: a, b: b} = var + # es6_maps: [map_style: :es6] + %{a: a, b: b} = var + %{a: a, b: b} = var + var + end + """, + formatted: """ + def test(var) do + %{a: a, b: b} = var + # es6_maps: [map_style: :es6] + %{a, b} = var + %{a: a, b: b} = var + var + end + """, + reverted: """ + def test(var) do + %{a: a, b: b} = var + # es6_maps: [map_style: :es6] + %{a, b} = var + %{a: a, b: b} = var + var + end + """ end describe "map update literal" do diff --git a/test/es6_maps_test/test/support/formatting_assertions.ex b/test/es6_maps_test/test/support/formatting_assertions.ex index e7402ce..7952b76 100644 --- a/test/es6_maps_test/test/support/formatting_assertions.ex +++ b/test/es6_maps_test/test/support/formatting_assertions.ex @@ -1,19 +1,17 @@ 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)) + opts = unquote(opts) + original = Keyword.fetch!(opts, :original) + formatted = Keyword.get(opts, :formatted, original) + reverted = Keyword.get(opts, :reverted, original) - Mix.Tasks.Es6Maps.Format.run([path]) - assert File.read!(path) == String.trim(unquote(formatted)) + formatting_opts = Keyword.get(opts, :opts, []) + vanilla_opts = Config.Reader.merge(opts, es6_maps: [map_style: :vanilla]) - Mix.Tasks.Es6Maps.Format.run([path, "--revert"]) - assert File.read!(path) == String.trim(unquote(reverted || original)) + assert Es6Maps.Formatter.format(original, formatting_opts) == formatted + assert Es6Maps.Formatter.format(formatted, vanilla_opts) == reverted end end end