Last active
July 3, 2024 12:16
-
-
Save jiegillet/e6357c82e36a848ad59295eb3d5a1135 to your computer and use it in GitHub Desktop.
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 Mix.Tasks.ConvertToVerifiedRoutes do | |
@moduledoc """ | |
Replaces routes with verified routes. | |
Forked from | |
https://gist.github.com/andreaseriksson/e454b9244a734310d4ab74d8595f98cd | |
This requires all routes to consistently be aliased with | |
alias MyAppWeb.Router.Helpers, as: Routes | |
Run with | |
mix convert_to_verified_routes | |
If you have routes only available in certain environments like tests, prepend MIX_ENV=test. | |
You might need to add `use MyAppWeb.VerifiedRoutes` in some places, and there | |
will most likely be some edge cases that will not get resolved properly. | |
See instructions at the bottom to format routes in your .heex files. | |
""" | |
use Mix.Task | |
@web_module MyAppWeb | |
def run(_) do | |
Path.wildcard("test/**/*.ex*") | |
|> Enum.concat(Path.wildcard("lib/**/*.ex*")) | |
|> Enum.sort() | |
|> Enum.filter(&(&1 |> File.read!() |> String.contains?("Routes."))) | |
|> Enum.each(&format_file/1) | |
:ok | |
end | |
def format_file(filename) do | |
Mix.shell().info(filename) | |
formatted_content = | |
filename | |
|> File.read!() | |
|> format_string() | |
File.write!(filename, [formatted_content, "\n"]) | |
end | |
def format_string(source) do | |
{ast, comments} = | |
Code.string_to_quoted_with_comments!(source, | |
literal_encoder: &{:ok, {:__block__, &2, [&1]}}, | |
unescape: false, | |
token_metadata: true | |
) | |
{app_opts, _} = Code.eval_file(".formatter.exs") | |
{phoenix_opts, _} = Code.eval_file("deps/phoenix/.formatter.exs") | |
# we needed form_for for our .heex files, you might need more | |
locals_without_parens = | |
[form_for: 3, form_for: 4] | |
|> Keyword.merge(Keyword.get(app_opts, :locals_without_parens)) | |
|> Keyword.merge(Keyword.get(phoenix_opts, :locals_without_parens)) | |
ast | |
|> Macro.prewalk(&replace_routes_alias/1) | |
|> Macro.prewalk(&replace_route/1) | |
|> Code.Formatter.to_algebra(comments: comments, locals_without_parens: locals_without_parens) | |
|> Inspect.Algebra.format(98) | |
end | |
defp decode_literal(literal) when is_binary(literal) or is_integer(literal) do | |
{:ok, literal} | |
end | |
defp decode_literal({:__block__, _, [literal]}) do | |
{:ok, literal} | |
end | |
defp decode_literal(node), do: {:error, node} | |
defp encode_literal(literal) do | |
{:__block__, [], [literal]} | |
end | |
# alias MyAppWeb.Router.Helpers, as: Routes -> use Elixir.AppsignalMyAppWeb.VerifiedRoutes | |
defp replace_routes_alias( | |
{:alias, _, | |
[ | |
{:__aliases__, _, [_web_module, :Router, :Helpers]}, | |
[ | |
{{:__block__, _, [:as]}, {:__aliases__, _, [:Routes]}} | |
] | |
]} | |
) do | |
{:use, [], [{:__aliases__, [], [@web_module, :VerifiedRoutes]}]} | |
end | |
defp replace_routes_alias(node), do: node | |
# Routes.url(MyAppWeb.Endpoint) | |
defp replace_route({{:., _, [{:__aliases__, _, [:Routes]}, :url]}, _, [_conn_or_endpoint]}) do | |
{:url, [], [{:sigil_p, [delimiter: "\""], [{:<<>>, [], ["/"]}, []]}]} | |
end | |
# Routes.static_path(conn, "/images/favicon.ico") | |
defp replace_route({{:., _, [{:__aliases__, _, [:Routes]}, :static_path]}, _, args}) do | |
[_conn_or_endpoint, path] = args | |
case decode_literal(path) do | |
{:ok, path} -> {:sigil_p, [delimiter: "\""], [{:<<>>, [], [path]}, []]} | |
_ -> {:sigil_p, [delimiter: "\""], [path, []]} | |
end | |
end | |
# Routes.static_url(conn, "/images/favicon.ico") | |
defp replace_route({{:., _, [{:__aliases__, _, [:Routes]}, :static_url]}, _, args}) do | |
[_conn_or_endpoint, path] = args | |
sigil = | |
case decode_literal(path) do | |
{:ok, path} -> {:sigil_p, [delimiter: "\""], [{:<<>>, [], [path]}, []]} | |
_ -> {:sigil_p, [delimiter: "\""], [path, []]} | |
end | |
{:url, [], [sigil]} | |
end | |
# conn |> Routes.some_path(:index, "en") | |
defp replace_route( | |
{:|>, _, | |
[conn_or_endpoint, {{:., _, [{:__aliases__, _, [:Routes]}, path_name]}, _, args}]} | |
) do | |
replace_route( | |
{{:., [], [{:__aliases__, [], [:Routes]}, path_name]}, [], [conn_or_endpoint | args]} | |
) | |
end | |
# Routes.some_path(conn, :action, "en", query_params) | |
defp replace_route( | |
{{:., _, [{:__aliases__, _, [:Routes]}, path_name]}, _, [_ | _] = args} = node | |
) do | |
[_conn_or_endpoint, action | params] = args | |
action = | |
case decode_literal(action) do | |
{:ok, action} -> action | |
_ -> action | |
end | |
path_name = "#{path_name}" | |
case find_verified_route(path_name, action, params) do | |
:ok -> node | |
route -> route | |
end | |
end | |
defp replace_route(node), do: node | |
defp find_verified_route(path_name, action, arguments) do | |
# pleaaaase don't have a route named Routes.product_url_path(conn, :index) | |
trimmed_path = path_name |> String.trim_trailing("_path") |> String.trim_trailing("_url") | |
route = | |
Phoenix.Router.routes(@web_module.Router) | |
|> Enum.find(fn %{helper: helper, plug_opts: plug_opts} -> | |
plug_opts == action && is_binary(helper) && trimmed_path == helper | |
end) | |
case route do | |
%{path: path} -> | |
{path_bits, query_params} = | |
path | |
|> String.split("/", trim: true) | |
|> replace_path_variables(arguments, []) | |
path_bits = | |
path_bits | |
|> Enum.flat_map(fn bit -> ["/", bit] end) | |
|> format_for_sigil_binary_args(query_params) | |
sigil = {:sigil_p, [delimiter: "\""], [{:<<>>, [], path_bits}, []]} | |
if String.ends_with?(path_name, "_url") do | |
{:url, [], [sigil]} | |
else | |
sigil | |
end | |
_ -> | |
Mix.shell().error( | |
"Could not find route #{path_name}, with action #{inspect(action)} and arguments #{inspect(arguments)}" | |
) | |
end | |
end | |
defp replace_path_variables([], arguments, path_bits) do | |
{Enum.reverse(path_bits), arguments} | |
end | |
defp replace_path_variables(path, [], path_bits) do | |
{Enum.reverse(path_bits) ++ path, []} | |
end | |
# conceptually /post/:post_id -> /post/#{id} | |
defp replace_path_variables([path_piece | rest], [arg | args], path_bits) do | |
if String.starts_with?(path_piece, ":") do | |
replace_path_variables(rest, args, [arg | path_bits]) | |
else | |
replace_path_variables(rest, [arg | args], [path_piece | path_bits]) | |
end | |
end | |
defp format_for_sigil_binary_args(path_bits, [_ | _] = query_params) do | |
format_for_sigil_binary_args(path_bits ++ ["?" | query_params], []) | |
end | |
defp format_for_sigil_binary_args(path_bits, []) do | |
path_bits | |
|> Enum.map(&decode_literal/1) | |
|> Enum.map(fn | |
{:ok, bit} when is_binary(bit) -> | |
bit | |
{:ok, bit} when is_atom(bit) or is_integer(bit) -> | |
to_string(bit) | |
{_, bit} -> | |
{:"::", [], | |
[ | |
{{:., [], [Kernel, :to_string]}, [from_interpolation: true], [encode_literal(bit)]}, | |
{:binary, [], Elixir} | |
]} | |
end) | |
end | |
# The rest is for hacking the Phoenix.LiveView.HTMLFormatter plugin to replace routes | |
# in .heex files. Patch deps/phoenix_live_view/lib/phoenix_live_view/html_formatter.ex:237 | |
# | |
# formatted = | |
# source | |
# |> tokenize() | |
# |> Enum.map(&Mix.Tasks.ConvertToVerifiedRoutes.format_eex/1) # <--- Add this line | |
# |> to_tree([], [], {source, newlines}) | |
# | |
# run with | |
# mix compile && mix deps.compile phoenix_live_view && mix format | |
# and when you're done | |
# mix deps.clean phoenix_live_view && mix deps.get | |
# tag attributes: <a href={Routes.some_path(conn, :index)}>link</a> | |
def format_eex({:tag, tag, attrs, meta}) do | |
{:tag, tag, Enum.map(attrs, &format_eex/1), meta} | |
end | |
# tag attributes of components: <._component path={Routes.some_path(conn, :index)} /> | |
def format_eex({component, tag, attrs, meta}) | |
when component in [:remote_component, :local_component] do | |
{component, tag, Enum.map(attrs, &format_eex/1), meta} | |
end | |
# attribute: href={Routes.some_path(conn, :index)} | |
def format_eex({attribute, {:expr, expr, meta}, attr_meta}) do | |
expr = | |
case String.contains?(expr, "Routes") && check_string(expr) do | |
{:ok, expr} -> expr | |
_ -> expr | |
end | |
{attribute, {:expr, expr, meta}, attr_meta} | |
end | |
# Eex expression: <%= Routes.some_path(conn, :index) %> | |
def format_eex({:eex, :expr, expr, meta}) do | |
expr = | |
case String.contains?(expr, "Routes") && check_string(expr) do | |
{:ok, expr} -> expr | |
_ -> expr | |
end | |
{:eex, :expr, expr, meta} | |
end | |
# Eex block: <%= if(url == Routes.some_path(conn, :index)) do %> | |
# warning, this part is frail, and relies on blocks ending in -> not having parens | |
# like <%= form_for @conn, Routes.some_path(conn, :index), args, fn f -> %> | |
# add functions to locals_without_parens in format_string/1 if needed | |
def format_eex({:eex, :start_expr, expr, meta}) do | |
expr = | |
case String.contains?(expr, "Routes") && check_string(expr <> " nil end") do | |
{:ok, expr} -> | |
expr | |
|> String.trim_trailing("end") | |
|> String.trim_trailing() | |
|> String.trim_trailing("nil") | |
|> String.trim_trailing() | |
_ -> | |
expr | |
end | |
{:eex, :start_expr, expr, meta} | |
end | |
def format_eex(node), do: node | |
defp check_string(source) do | |
case format_string(source) do | |
:ok -> :error | |
io_data -> {:ok, IO.iodata_to_binary(io_data)} | |
end | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Glad this helped, all your changes make sense and I appreciate the feedback :)