Skip to content

Commit

Permalink
Add a mix format plugin. (#4)
Browse files Browse the repository at this point in the history
  • Loading branch information
kzemek authored May 1, 2024
1 parent fcc91d1 commit 8f921f1
Show file tree
Hide file tree
Showing 7 changed files with 250 additions and 165 deletions.
52 changes: 39 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()
]
Expand Down Expand Up @@ -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.
Expand Down
152 changes: 152 additions & 0 deletions lib/es6_maps/formatter.ex
Original file line number Diff line number Diff line change
@@ -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
137 changes: 0 additions & 137 deletions lib/mix/tasks/es6_maps/format.ex

This file was deleted.

3 changes: 1 addition & 2 deletions test/es6_maps_test/mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 0 additions & 3 deletions test/es6_maps_test/mix.lock

This file was deleted.

Loading

0 comments on commit 8f921f1

Please sign in to comment.