improvement: initial implementation of ash resource formatter

This commit is contained in:
Zach Daniel 2022-02-02 17:02:28 -05:00
parent 9eff65758c
commit 4138bd4934
4 changed files with 240 additions and 0 deletions

View file

@ -137,6 +137,7 @@ locals_without_parens = [
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
locals_without_parens: locals_without_parens,
plugins: [Ash.ResourceFormatter],
export: [
locals_without_parens: locals_without_parens
]

View file

@ -0,0 +1,234 @@
defmodule Ash.ResourceFormatter do
@moduledoc """
Formats Ash resources. WARNING: This is untested, use at your own risk! *do not run without commiting your code first*!
Currently, it is very simple, and will only reorder the outermost sections according to some rules.
# Plugin
Include the plugin into your `.formatter.exs` like so `plugins: [Ash.ResourceFormatter]`.
If no configuration is provided, it will sort all top level DSL sections *alphabetically*.
# Section Order
To provide a custom section order (for both ash and any extensions), add configuration to your app, for example:
```elixir
config :ash, :formatter,
section_order: [
:resource,
:postgres,
:attributes,
:relationships,
:aggregates,
:calculations
]
```
Any sections found in your resource that aren't in that list will be left in the order that they were in, the sections
in the list will be sorted "around" those sections. E.g the following list: `[:code_interface, :attributes]` can be interpreted as
"ensure that code_interface comes before attributes, and don't change the rest".
# Using something other than `Ash.Resource`
The resource formatter triggers when it sees a `use Ash.Resource` in a module. In some cases, you may be
"use"ing a different module, e.g `use MyApp.Resource`. To support this, you can configure the `using_modules`, like so:
```elixir
config :ash, :formatter,
using_modules: [Ash.Resource, MyApp.Resource]
```
"""
@behaviour Mix.Tasks.Format
def features(_opts) do
[extensions: [".ex", ".exs"]]
end
def format(contents, opts) do
IO.inspect(opts)
using_modules = Application.get_env(:ash, :formatter)[:using_modules] || [Ash.Resource]
contents
|> Sourceror.parse_string!()
|> format_resources(opts, using_modules)
|> IO.inspect()
|> then(fn patches ->
Sourceror.patch_string(contents, patches)
end)
|> Code.format_string!(opts_without_plugin(opts))
|> then(fn iodata ->
[iodata, ?\n]
end)
|> IO.iodata_to_binary()
# rescue
# e ->
# IO.inspect(e)
# contents
end
defp format_resources(parsed, opts, using_modules) do
{_, patches} =
Macro.prewalk(parsed, [], fn
{:defmodule, _, [_, [{{:__block__, _, [:do]}, {:__block__, _, body}}]]} = expr, patches ->
case get_extensions(body, using_modules) do
{:ok, extensions} ->
IO.inspect(extensions, label: "extensions")
replacement = format_resource(body, extensions)
patches =
body
|> Enum.zip(replacement)
|> Enum.reduce(patches, fn {body_section, replacement_section}, patches ->
if body_section == replacement_section do
patches
else
[
%{
range: Sourceror.get_range(body_section, include_comments: true),
change: Sourceror.to_string(replacement_section, opts)
}
| patches
]
end
end)
{expr, patches}
_ ->
{expr, patches}
end
expr, patches ->
{expr, patches}
end)
patches
end
defp format_resource(body, extensions) do
sections =
[Ash.Resource.Dsl | extensions]
|> Enum.flat_map(fn extension ->
Enum.map(extension.sections(), fn section ->
{extension, section}
end)
end)
|> sort_sections()
section_names = Enum.map(sections, fn {_, section} -> section.name end)
{section_exprs, non_section_exprs} =
body
|> Enum.split_with(fn {name, _, _} ->
name in section_names
end)
new_sections =
section_names
|> Enum.flat_map(fn section_name ->
matching_section =
Enum.find(section_exprs, fn
{^section_name, _, _} -> true
_ -> nil
end)
case matching_section do
nil ->
[]
section_expr ->
[section_expr]
end
end)
Enum.concat(non_section_exprs, new_sections)
end
defp sort_sections(sections) do
case Application.get_env(:ash, :formatter)[:section_order] do
nil ->
Enum.sort_by(sections, fn {_extension, section} ->
section.name
end)
section_order ->
{ordered, unordered} =
sections
|> Enum.with_index()
|> Enum.split_with(fn {{_, section}, _} ->
section.name in section_order
end)
reordered =
ordered
|> Enum.map(&elem(&1, 0))
|> Enum.sort_by(fn {_, section} ->
Enum.find_index(section_order, &(&1 == section.name))
end)
Enum.reduce(unordered, reordered, fn {{extension, section}, i}, acc ->
List.insert_at(acc, i, {extension, section})
end)
end
end
defp get_extensions(body, using_modules) do
Enum.find_value(body, :error, fn
{:use, _, using} ->
[using, opts] =
case Ash.Dsl.Extension.expand_alias(using, __ENV__) do
[using] ->
[using, []]
[using, opts] ->
[using, opts]
end
|> IO.inspect()
if using in using_modules do
{:ok, parse_extensions(opts)}
end
_ ->
nil
end)
end
defp parse_extensions(blocks) do
blocks
|> Enum.flat_map(fn {{:__block__, _, _}, extensions} ->
extensions
|> case do
{:__block__, _, [extensions]} ->
extensions
extension when is_atom(extension) ->
extension
_ ->
[]
end
|> List.wrap()
|> Enum.flat_map(fn extension ->
case Code.ensure_compiled(extension) do
{:module, module} ->
if Ash.Helpers.implements_behaviour?(module, Ash.Dsl.Extension) do
[module]
else
[]
end
_ ->
[]
end
end)
end)
end
defp opts_without_plugin(opts) do
Keyword.update(opts, :plugins, [], &(&1 -- [__MODULE__]))
end
end

View file

@ -111,6 +111,9 @@ defmodule Ash.MixProject do
Ash.Filter,
Ash.Sort
],
formatting: [
Ash.ResourceFormatter
],
validations: ~r/Ash.Resource.Validation/,
changes: ~r/Ash.Resource.Change/,
calculations: [
@ -199,6 +202,7 @@ defmodule Ash.MixProject do
{:timex, ">= 3.0.0"},
{:comparable, "~> 1.0"},
{:jason, ">= 1.0.0"},
{:sourceror, "~> 0.9"},
# Dev/Test dependencies
{:ex_doc, "~> 0.22", only: :dev, runtime: false},
{:ex_check, "~> 0.12.0", only: :dev},

View file

@ -36,6 +36,7 @@
"picosat_elixir": {:hex, :picosat_elixir, "0.2.0", "1c9d2d65b32039c9e3eb600bff903579e5916f559dbf0013b3c4ca617c93ac64", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "7900c6d58d65a9d8a2899f53019fb70e9b9678161bbb53f646e28e146aca138c"},
"providers": {:hex, :providers, "1.8.1", "70b4197869514344a8a60e2b2a4ef41ca03def43cfb1712ecf076a0f3c62f083", [:rebar3], [{:getopt, "1.0.1", [hex: :getopt, repo: "hexpm", optional: false]}], "hexpm", "e45745ade9c476a9a469ea0840e418ab19360dc44f01a233304e118a44486ba0"},
"sobelow": {:hex, :sobelow, "0.11.1", "23438964486f8112b41e743bbfd402da3e5b296fdc9eacab29914b79c48916dd", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "9897363a7eff96f4809304a90aad819e2ad5e5d24db547af502885146746a53c"},
"sourceror": {:hex, :sourceror, "0.9.0", "77e8f883be9455812d15913582d2985048ef65d7d931072c548e025a6ea58d5a", [:mix], [], "hexpm", "f56fb5b935df7784504f7d1ba074e0aa83299e2ebd64f75268ffcae62a28f331"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"},
"telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"},
"timex": {:hex, :timex, "3.7.5", "3eca56e23bfa4e0848f0b0a29a92fa20af251a975116c6d504966e8a90516dfd", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "a15608dca680f2ef663d71c95842c67f0af08a0f3b1d00e17bbd22872e2874e4"},