defmodule TopSecret do
@spec to_ast(String.t()) :: tuple()
def to_ast(string), do: Code.string_to_quoted!(string)
@spec decode_secret_message_part(tuple(), list()) :: tuple()
def decode_secret_message_part(ast, acc) when elem(ast, 0) in [:def, :defp] do
ast
|> elem(2)
|> Enum.at(0)
|> get_function_ast()
|> get_function_name_and_arity()
|> get_sliced_name()
|> get_secret_message_part(ast, acc)
end
def decode_secret_message_part(ast, acc), do: {ast, acc}
defp get_secret_message_part(name, ast, acc), do: {ast, [name | acc]}
defp get_function_ast(ast) when elem(ast, 0) == :when do
ast
|> elem(2)
|> Enum.at(0)
end
defp get_function_ast(ast), do: ast
defp get_function_name_and_arity(function_ast) do
fname =
function_ast
|> elem(0)
|> Atom.to_string()
{fname, get_arity(function_ast)}
end
defp get_arity(function_ast) when elem(function_ast, 2) == nil, do: -1
defp get_arity(function_ast) do
function_ast
|> elem(2)
|> Enum.count()
|> Kernel.-(1)
end
defp get_sliced_name({_fname, arity}) when arity == -1, do: ""
defp get_sliced_name({fname, arity}) when arity > -1, do: fname |> String.slice(0..arity)
@spec decode_secret_message(String.t()) :: String.t()
def decode_secret_message(string) do
string
|> to_ast()
|> Macro.postwalk([], &decode_secret_message_part/2)
|> Tuple.to_list()
|> Enum.at(1)
|> Enum.reverse()
|> Enum.join()
end
end
Notes
I struggled with this one a lot. My first version of the code was not idiomatic at all. It is too verbose and relies a lot on Enum.at and elem. That's in comparison to using pattern matching.
In my second iteration I've borrowed some improvements from others. Namely, checking if the expression is a function with a in [:def, :defp] which I was checking using a conditional statement before.
But I still decided to keep my code instead of using pattern matching. Because of the complexity of the AST data structure, the pattern matching is rather complicated and I feel it's not as readable. Despite it being shorter. But this is really just a preference. Most consider the solutions that rely on pattern matching heavily more elegant.
I have also learned about and I have started using it. It helps me run all tests on every save. I install the dependency:
# mix.exs (v1.13)
def deps do
[
{:mix_test_watch, "~> 1.0", only: :dev}
]
end
I configure it in my project:
# config/config.exs
import Config
if config_env() == :dev do
config :mix_test_watch,
clear: true
end
I have VSCode and iTerm side by side and I work on my code. On every save, the tests run in iTerm. I have reduced using iex dramatically as I am adding IO.inspect statements that immediatelly show up in iTerm after each save.