Skip to content

Instantly share code, notes, and snippets.

@novaugust
Last active March 1, 2023 21:35
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save novaugust/badfb2a1fe1c7de2d7c76b31b046dafa to your computer and use it in GitHub Desktop.
Save novaugust/badfb2a1fe1c7de2d7c76b31b046dafa to your computer and use it in GitHub Desktop.
Credo Rewrites Via Sourceror
# 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
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
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
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
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
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
Copy link

Tuxified commented Jan 7, 2022

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

@novaugust
Copy link
Author

@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