Last active
March 1, 2023 21:35
-
-
Save novaugust/badfb2a1fe1c7de2d7c76b31b046dafa to your computer and use it in GitHub Desktop.
Credo Rewrites Via Sourceror
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# for an example of a suggested `locals_without_parens`, see z_locals_without_parens.exs | |
# parsing and expanding a formatter.exs file would be a good route too | |
opts = [sourceror_opts: [locals_without_parens: [...], line_length: 122]] | |
for transformation <- [&PipeChainStart.run/1, &SinglePipe.run/1] do | |
ProjTraversal.transform("../my_codebase/", transformation, opts) | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
defmodule PipeChainStart do | |
alias Sourceror.Zipper | |
@doc """ | |
runs a transformation on the ast to make it compliant with Credo's PipeChainStart rule | |
known bug: | |
does not rewrite infix operators, and infix operators cannot be excluded from credo's check | |
https://github.com/rrrene/credo/issues/925 | |
this means you'll still get credo failures after running this test if you have code like | |
(a / b) |> Float.ceil | |
""" | |
def run(ast) do | |
Zipper.zip(ast) # lol get it | |
|> Zipper.traverse(&check_node/1) | |
|> Zipper.root() | |
end | |
@doc "useful for seeing how `run/1` will rewrite a block of code" | |
def test(code) do | |
code | |
|> Sourceror.parse_string!() | |
|> run() | |
|> Sourceror.to_string() | |
|> IO.puts | |
end | |
defp check_node({{:|>, _, [{:|>, _, _} | _]}, _} = zipper), do: zipper | |
defp check_node({{:|>, _, [lhs, _rhs]}, _} = zipper) do | |
if valid_chain_start?(lhs) do | |
zipper | |
else | |
rewrite(zipper) | |
end | |
end | |
defp check_node(zipper), do: zipper | |
# from: | |
# | |
# case x do | |
# y -> z | |
# end | |
# |> a() | |
# |> b() | |
# | |
# to: | |
# | |
# case_result = | |
# case x do | |
# y -> z | |
# end | |
# | |
# case_result | |
# |> a() | |
# |> b() | |
defp rewrite({{:|>, pipe_meta, [{block, _, _} = expression, rhs]}, _} = zipper) when block in ~w(case if)a do | |
variable = {:"#{block}_result", [], nil} | |
zipper | |
|> Zipper.replace({:|>, pipe_meta, [variable, rhs]}) | |
|> find_or_create_assignment_location() | |
|> Zipper.insert_left({:=, [], [variable, expression]}) | |
end | |
defp rewrite({{:|>, pipe_meta, [lhs, rhs]}, _} = zipper) do | |
lhs_rewrite = | |
case lhs do | |
{{:., dot_meta, dot_args}, args_meta, [arg | args]} -> | |
{:|>, args_meta, [arg, {{:., [], dot_args}, dot_meta, args}]} | |
{atom, meta, [arg | args]} when is_atom(atom) -> | |
{:|>, meta, [arg, {atom, [], args}]} | |
end | |
Zipper.replace(zipper, {:|>, pipe_meta, [lhs_rewrite, rhs]}) | |
end | |
# this really needs a better name. | |
defp find_or_create_assignment_location(zipper) do | |
case Zipper.up(zipper) do | |
{{:|>, _, _}, _} = parent -> | |
find_or_create_assignment_location(parent) | |
# var = | |
# if ... end | |
# |> foo() | |
# ----- | |
# if_result = if ... end; | |
# var = if_result |> foo() | |
{{:=, _, _}, _} = parent -> | |
find_or_create_assignment_location(parent) | |
# def fun do | |
# case do end | |
# |> f() | |
# end | |
{{{:__block__, _, _}, {:|>, _, _}}, _} -> | |
replace_with_block(zipper) | |
# fn -> | |
# case do end | |
# |> b() | |
# end | |
{{:->, _, [_, {:|>, _, _} | _]}, _} -> | |
replace_with_block(zipper) | |
_too_high -> | |
zipper | |
end | |
end | |
# give it a block parent, then step back to the pipe - we can insert next to it now that it's in a block | |
defp replace_with_block(zipper) do | |
zipper | |
|> Zipper.replace({:__block__, [], [Zipper.node(zipper)]}) | |
|> Zipper.next() | |
end | |
# most of this code was lifted directly from credo's pipe_chain_start.ex, with some additions | |
for atom <- [ | |
:%, | |
:%{}, | |
:.., | |
:<<>>, | |
:@, | |
:__aliases__, | |
:unquote, | |
:{}, | |
:&, | |
:<>, | |
:++, | |
:--, | |
:&&, | |
:||, | |
:for, | |
:with, | |
# custom stuff from here | |
# ecto | |
:from, | |
# sourceror's ast parsing | |
:__block__, | |
# TODO infix operators could be rewritten to `|> Kernel.op` | |
# math | |
:-, | |
:*, | |
:+, | |
:/, | |
# comparison | |
:>, | |
:<, | |
:<=, | |
:>= | |
] do | |
defp valid_chain_start?({unquote(atom), _meta, _arguments}), do: true | |
end | |
for operator <- [ | |
:<-, | |
:|||, | |
:&&&, | |
:<<<, | |
:>>>, | |
:<<~, | |
:~>>, | |
:<~, | |
:~>, | |
:<~>, | |
:<|>, | |
:^^^, | |
:~~~ | |
] do | |
defp valid_chain_start?({unquote(operator), _meta, _arguments}), do: true | |
end | |
# anonymous function | |
defp valid_chain_start?({:fn, _, [{:->, _, [_args, _body]}]}), do: true | |
# variable | |
defp valid_chain_start?({atom, _, nil}) when is_atom(atom), do: true | |
# function_call() | |
defp valid_chain_start?({atom, _, []}) when is_atom(atom), do: true | |
# function_call(with, args) and sigils. only sigils are valid | |
defp valid_chain_start?({atom, _, arguments}) when is_atom(atom) and is_list(arguments) do | |
String.match?("#{atom}", ~r/^sigil_[a-zA-Z]$/) | |
end | |
# map[:access] | |
defp valid_chain_start?({{:., _, [Access, :get]}, _, _}), do: true | |
# Module.function_call() | |
defp valid_chain_start?({{:., _, _}, _, []}), do: true | |
# '__#{val}__' are compiled to List.to_charlist("__#{val}__") | |
# we want to consider these charlists a valid pipe chain start | |
defp valid_chain_start?({{:., _, [List, :to_charlist]}, _, [[_ | _]]}), do: true | |
# Module.function_call(with, parameters) | |
defp valid_chain_start?({{:., _, _}, _, _}), do: false | |
defp valid_chain_start?(_), do: true | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
defmodule ProjTraversal do | |
@doc """ | |
walks the directory, calling `fun` on each elixir source. | |
if fun is an mf or mfa, the mfa is called with the file's ast prepended as the first argument | |
### options | |
- `file_ext` (default: `[".ex", ".exs"]`): files with matching extensions will be transformed | |
- `sourceror_opts`: passed to `Sourceror.to_string/2` for formatting the transformed ast back into code | |
""" | |
def transform(directory_path, fun_or_mf_or_mfa, opts \\ []) | |
def transform(dir, {m, f}, opts), do: transform(dir, {m, f, []}, opts) | |
def transform(dir, {m, f, a}, opts), do: transform(dir, &apply(m, f, [&1 | a]), opts) | |
def transform(dir, fun, opts) when is_function(fun, 1) do | |
file_ext = opts[:file_ext] || [".ex", ".exs"] | |
sourceror_opts = opts[:sourceror_opts] | |
dir | |
|> expand() | |
|> walk(fun, file_ext, sourceror_opts) | |
end | |
defp walk([file | files], fun, file_ext, sourceror_opts) do | |
if File.dir?(file) do | |
nested_files = expand(file) | |
walk(files ++ nested_files, fun, file_ext, sourceror_opts) | |
else | |
# rewrite the file | |
if String.ends_with?(file, file_ext) do | |
try do | |
original = String.trim(File.read!(file)) | |
quoted = Sourceror.parse_string!(original) | |
transformed = fun.(quoted) | |
rewrite = Sourceror.to_string(transformed, sourceror_opts) | |
if original != rewrite do | |
IO.puts "rewriting #{file}" | |
File.write!(file, [rewrite, "\n"]) | |
else | |
IO.puts "skipping #{file}" | |
end | |
rescue | |
value -> | |
IO.puts "ERROR #{file}" | |
IO.puts :stderr, Exception.format(:error, value, __STACKTRACE__) | |
end | |
end | |
walk(files, fun, file_ext, sourceror_opts) | |
end | |
end | |
defp walk([], _, _, _) do | |
IO.puts "Done" | |
end | |
defp expand(dir) do | |
dir | |
|> File.ls!() | |
|> Enum.map(&Path.join(dir, &1)) | |
end | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
defmodule SinglePipe do | |
alias Sourceror.Zipper | |
@doc """ | |
Rewrite single pipes into function calls to satisfy credo's `Credo.Check.Readability.SinglePipe` rule | |
Run this transformation _after_ the PipeChainStart transformation to pretent `a(b) |> c()` from collapsing to `c(a(b))` | |
""" | |
def run(ast) do | |
ast | |
|> Zipper.zip() | |
|> Zipper.traverse(&rewrite_pipe/1) | |
|> Zipper.root() | |
end | |
defp rewrite_pipe({{:|>, _, [{:|>, _, _} | _]}, _} = zipper), do: consume_valid_pipeline(zipper) | |
# there are edge cases where ignoring _fun_meta here will drop comments, but honestly they're so rare i'm not fretting it | |
defp rewrite_pipe({{:|>, pipe_meta, [arg, {fun, _fun_meta, args}]}, _} = zipper) do | |
Zipper.replace(zipper, {fun, pipe_meta, [arg | args]}) | |
end | |
defp rewrite_pipe(zipper), do: zipper | |
# keep walking the tree until we're on something that isn't part of the valid pipeline, | |
# then resume our pipe replacement strategy | |
defp consume_valid_pipeline({{:|>, _, _}, _} = zipper), do: zipper |> Zipper.next() |> consume_valid_pipeline() | |
defp consume_valid_pipeline(zipper), do: zipper | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
defmodule TransactionsToMulti do | |
@moduledoc """ | |
Rewrite `Transactions.add_operation/[3,4]` to `Multi.run/3` | |
### Example | |
**input**: | |
multi | |
|> Transactions.add_operation(:a, fn b -> c(b) end, requires: :b) | |
|> Transactions.add_operation(:b, fn -> fun() end) | |
|> Transactions.add_operation(:c, fn %{map_a: a, map_b: b} -> fun(a, b) end, requires: [:map_a, :map_b]) | |
|> Transactions.add_operation(:d, &foo/0) | |
|> Transactions.add_operation(:e, &foo/1, requires: :a) | |
|> Transactions.add_operation(:f, &foo(&1, bar), requires: :a) | |
|> Transactions.add_operation(:f, &foo(&1, bar, &1.id.x), requires: :a) | |
|> Transactions.add_operation(:g, &foo(&1.a, &1.b, c), requires: [:a, :b]) | |
|> Transactions.add_operation(:f, fn | |
%{match: :x} = bar -> {:ok, bar} | |
_ -> {:error, :kaboom} | |
end, requires: :a) | |
**output**: | |
multi | |
|> Multi.run(:a, fn _, %{b: b} -> c(b) end) | |
|> Multi.run(:b, fn _, _ -> fun() end) | |
|> Multi.run(:c, fn _, %{map_a: a, map_b: b} -> fun(a, b) end) | |
|> Multi.run(:d, fn _, _ -> foo() end) | |
|> Multi.run(:e, fn _, %{a: a} -> foo(a) end) | |
|> Multi.run(:f, fn _, %{a: a} -> foo(a, bar) end) | |
|> Multi.run(:f, fn _, %{a: a} -> foo(a, bar, a.id.x) end) | |
|> Multi.run(:g, fn _, changes -> foo(changes.a, changes.b, c) end) | |
|> Multi.run(:f, fn | |
_, %{a: %{match: :x} = bar} -> {:ok, bar} | |
_, _ -> {:error, :kaboom} | |
end) | |
""" | |
alias Sourceror.Zipper | |
@doc """ | |
Rewrite single pipes into function calls to satisfy credo's `Credo.Check.Readability.SinglePipe` rule | |
Run this transformation _after_ the PipeChainStart transformation to pretent `a(b) |> c()` from collapsing to `c(a(b))` | |
""" | |
def run(ast) do | |
ast | |
|> Zipper.zip() | |
|> Zipper.traverse(&rewrite/1) | |
|> Zipper.root() | |
end | |
@doc "useful for seeing how `run/1` will rewrite a block of code" | |
def test(code) do | |
code | |
|> Sourceror.parse_string!() | |
|> run() | |
|> Sourceror.to_string() | |
|> IO.puts | |
end | |
# |> Transactions.add_operation(:trx_name, fn fun_args end, ?[requires: :a | [:a, :b, ...]]) | |
defp rewrite({{{:., dot_meta, [{:__aliases__, trx_meta, [:Transactions]}, :add_operation]}, fn_meta, args}, _tree} = zipper) do | |
[step_name, fun_or_capture | maybe_requires] = args | |
requires = List.first(maybe_requires) | |
new_node = { | |
{:., dot_meta, [{:__aliases__, trx_meta, [:Multi]}, :run]}, | |
fn_meta, | |
[step_name, rewrite_op_args(fun_or_capture, requires)] | |
} | |
Zipper.replace(zipper, new_node) | |
end | |
defp rewrite(zipper), do: zipper | |
@wildcard {:_, [], nil} | |
# anonymous functions | |
defp rewrite_op_args({:fn, fun_meta, arrows}, requires) do | |
arrows = | |
Enum.map(arrows, fn {:->, arrow_meta, [arrow_params, arrow_body]} -> | |
arrow_params = [@wildcard, rewrite_arrow_param(arrow_params, requires)] | |
{:->, arrow_meta, [arrow_params, arrow_body]} | |
end) | |
{:fn, fun_meta, arrows} | |
end | |
# captures | |
defp rewrite_op_args({:&, cap_meta, cap_args}, requires) do | |
{arrow_param, arrow_body} = rewrite_capture(cap_args, requires) | |
arrow_params = [@wildcard, arrow_param] | |
{:fn, cap_meta, [{:->, cap_meta, [arrow_params, arrow_body]}]} | |
end | |
#&foo(&1.bar, &1.baz) | |
defp rewrite_arrow_param(arrow_params, maybe_requires) | |
# useful for multiple arrowheads like | |
# fn | |
# %{match: :x} -> ... | |
# _ -> ... | |
# end, requires: :x | |
# keeps the second clause as `_, _` instead of `_, %{x: _}` | |
defp rewrite_arrow_param([{:_, _, _} = wildcard_with_meta], _) do | |
wildcard_with_meta | |
end | |
# fn x -> end, requires: :previous_step | |
# fn _, %{previous_step: x} -> end | |
defp rewrite_arrow_param([var], [{{_, _, [:requires]}, {_, _, [previous_step]}}]) when is_atom(previous_step) do | |
map_for_var(previous_step, var) | |
end | |
# fn %{previous: a, steps: b} -> end, requires: [:previous, :steps] | |
# fn _, %{previous: a, steps: b} -> end | |
defp rewrite_arrow_param([map], [{{_, _, [:requires]}, {_, _, [previous_steps]}}]) when is_list(previous_steps) do | |
map | |
end | |
# params -> body | |
defp rewrite_arrow_param(_, _b) do | |
@wildcard | |
end | |
# &fun/0 or &fun/1 | |
defp rewrite_capture([{:/, _, [{fun, fun_meta, _}, {:__block__, _, [arity]}]}], requires) do | |
case arity do | |
# &fun/0 | |
# _, _ -> fun.() | |
0 -> | |
arrow_body = {fun, fun_meta, []} | |
{@wildcard, arrow_body} | |
# &fun/1, requires: previous_step | |
# _, %{previous_step: previous_step} -> fun.(previous_step) | |
1 -> | |
[{{_, _, [:requires]}, {_, _, [previous_step]}}] = requires | |
var = {previous_step, [], nil} | |
arrow_param = map_for_var(previous_step, var) | |
arrow_body = {fun, fun_meta, [var]} | |
{arrow_param, arrow_body} | |
end | |
end | |
#&foo(&1, bar), requires: previous_step | |
# _, %{previous_step: previous_step} -> foo.(previous_step, bar) | |
defp rewrite_capture([{fun, fun_meta, fun_params}], [{{_, _, [:requires]}, {_, _, [previous_step]}}]) when is_atom(previous_step) do | |
var = {previous_step, [], nil} | |
fun_params = rewrite_capture_params(fun_params, var) | |
arrow_param = map_for_var(previous_step, var) | |
arrow_body = {fun, fun_meta, fun_params} | |
{arrow_param, arrow_body} | |
end | |
# &foo(&1.a, &1.b, c), requires: [:a, :b] | |
# _, changes -> foo(changes.a, changes.b, c) | |
defp rewrite_capture([{fun, fun_meta, fun_params}], [{{_, _, [:requires]}, {_, _, [steps]}}]) when is_list(steps) do | |
var = {:changes, [], nil} | |
fun_params = rewrite_capture_params(fun_params, var) | |
arrow_param = var | |
arrow_body = {fun, fun_meta, fun_params} | |
{arrow_param, arrow_body} | |
end | |
# traverse ASTs looking for captures to replace with var | |
defp rewrite_capture_params(list, rewrite) when is_list(list) do | |
Enum.map(list, &rewrite_capture_params(&1, rewrite)) | |
end | |
# do the replacement | |
defp rewrite_capture_params({:&, _, [1]}, rewrite), do: rewrite | |
# op can also be {op, meta, args} so we recurse it as well | |
defp rewrite_capture_params({op, meta, args}, rewrite) when is_list(args) do | |
{rewrite_capture_params(op, rewrite), meta, rewrite_capture_params(args, rewrite)} | |
end | |
defp rewrite_capture_params(arg, _rewrite), do: arg | |
# ast for | |
# %{step: var} | |
defp map_for_var(step, var) do | |
{:%{}, [], [ | |
{{:__block__, [format: :keyword], List.wrap(step)}, var} | |
]} | |
end | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
locals_without_parens = [ | |
# Phoenix.Channel | |
intercept: 1, | |
# Phoenix.Router | |
connect: 3, | |
connect: 4, | |
delete: 3, | |
delete: 4, | |
forward: 2, | |
forward: 3, | |
forward: 4, | |
get: 3, | |
get: 4, | |
head: 3, | |
head: 4, | |
match: 4, | |
match: 5, | |
options: 3, | |
options: 4, | |
patch: 3, | |
patch: 4, | |
pipeline: 2, | |
pipe_through: 1, | |
post: 3, | |
post: 4, | |
put: 3, | |
put: 4, | |
resources: 2, | |
resources: 3, | |
resources: 4, | |
trace: 4, | |
# Phoenix.Controller | |
action_fallback: 1, | |
# Phoenix.Endpoint | |
plug: 1, | |
plug: 2, | |
socket: 2, | |
socket: 3, | |
# Phoenix.Socket | |
channel: 2, | |
channel: 3, | |
# Phoenix.ChannelTest | |
assert_broadcast: 2, | |
assert_broadcast: 3, | |
assert_push: 2, | |
assert_push: 3, | |
assert_reply: 2, | |
assert_reply: 3, | |
assert_reply: 4, | |
refute_broadcast: 2, | |
refute_broadcast: 3, | |
refute_push: 2, | |
refute_push: 3, | |
refute_reply: 2, | |
refute_reply: 3, | |
refute_reply: 4, | |
# Phoenix.ConnTest | |
assert_error_sent: 2, | |
# Phoenix.Live{Dashboard,View}.Router | |
live: 2, | |
live: 3, | |
live: 4, | |
live_dashboard: 1, | |
live_dashboard: 2, | |
on_mount: 1, | |
#ecto | |
# Query | |
from: 2, | |
# Schema | |
field: 1, | |
field: 2, | |
field: 3, | |
timestamps: 1, | |
belongs_to: 2, | |
belongs_to: 3, | |
has_one: 2, | |
has_one: 3, | |
has_many: 2, | |
has_many: 3, | |
many_to_many: 2, | |
many_to_many: 3, | |
embeds_one: 2, | |
embeds_one: 3, | |
embeds_one: 4, | |
embeds_many: 2, | |
embeds_many: 3, | |
embeds_many: 4, | |
#streamdata | |
all: :*, | |
check: 1, | |
check: 2, | |
property: 1, | |
property: 2, | |
# ecto sql | |
add: 2, | |
add: 3, | |
alter: 2, | |
create: 1, | |
create: 2, | |
create_if_not_exists: 1, | |
create_if_not_exists: 2, | |
drop: 1, | |
drop_if_exists: 1, | |
execute: 1, | |
execute: 2, | |
modify: 2, | |
modify: 3, | |
remove: 1, | |
remove: 2, | |
remove: 3, | |
rename: 2, | |
rename: 3, | |
timestamps: 1 | |
] |
@Tuxified given the amount of code I stole verbatim from credo, it's probably got to be under whatever license they're using ^.^ (MIT)
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Hi, may I ask under what license you published this? I'm asking as I'd like to write an "auto-fix" addition to Credo, similar to how RuboCop (Ruby's equivalent of Credo) can automatically fix minor issues