Skip to content

Instantly share code, notes, and snippets.

@rrrene
Created April 12, 2018 11:29
Show Gist options
  • Save rrrene/6aa14d5b2b3441c96a8e47b4ef12958e to your computer and use it in GitHub Desktop.
Save rrrene/6aa14d5b2b3441c96a8e47b4ef12958e to your computer and use it in GitHub Desktop.
defmodule Converter do
def convert_image(_a, _b, _c) do
:ok
end
end
defmodule Converter.StepBuilder do
defmacro __using__(_opts \\ []) do
quote do
Module.register_attribute(__MODULE__, :steps, accumulate: true)
@before_compile Converter.StepBuilder
import Converter.StepBuilder
def call(token) do
do_call(token)
end
end
end
defmacro step(do: clauses) do
Enum.reduce(clauses, nil, fn {:->, _, [[conditions], args]}, acc ->
# we collect all calls inside the current `->` block
quoted_calls =
case args do
{:__block__, _, quoted_calls} -> quoted_calls
single_quoted_call -> [single_quoted_call]
end
# and add conditions where applicable
quote do
unquote(acc)
unquote(add_conditions(quoted_calls, conditions))
end
end)
end
defmacro step(module) do
quote do
@steps {unquote(module), true}
end
end
defmacro step(module, if: conditions) do
quote do
# the second element of the tuple stores the given condition
@steps {unquote(module), unquote(Macro.escape(conditions))}
end
end
defp add_conditions(list, conditions) when is_list(list) do
Enum.map(list, &add_conditions(&1, conditions))
end
# quoted calls to our step/1 DSL macro look like this:
#
# {:step, _, [MyStepModule]}
#
# so all we have to do is append the `if:` condition
#
# {:step, _, [MyStepModule, [if: conditions]]}
#
defp add_conditions({:step, meta, args}, conditions) do
{:step, meta, args ++ [[if: conditions]]}
end
# if we encounter any other calls, we just leave them intact
defp add_conditions(ast, _conditions) do
ast
end
defmacro __before_compile__(env) do
steps = Module.get_attribute(env.module, :steps)
body = Converter.StepBuilder.compile(steps)
quote do
defp do_call(token) do
unquote(body)
end
end
|> inspect_formatted
end
def inspect_formatted(thing) do
thing
|> Macro.to_string()
|> Code.format_string!()
|> IO.puts()
thing
end
def compile(steps) do
token = quote do: token
# we use Enum.reduce/2 like before, but this time we are compiling all the
# calls at compile-time into one giant case-statement
Enum.reduce(steps, token, &compile_step/2)
end
defp compile_step({step, conditions}, acc) do
quoted_call =
quote do
unquote(step).call(token)
end
quote do
# instead of just calling the Step, we are compiling the given conditions
# into the call
result = unquote(compile_conditions(quoted_call, conditions))
case result do
%Converter.Token{} = token ->
unquote(acc)
_ ->
raise unquote("expected #{inspect(step)}.call/1 to return a Token")
end
end
end
defp compile_conditions(quoted_call, true) do
# if no conditions were given, we simply call the Step
quoted_call
end
defp compile_conditions(quoted_call, conditions) do
quote do
# we have to use `var!/1` for our variable to be accessible
# by the code inside `conditions`
var!(token) = token
# to avoid "unused variable" warnings, we assign the variable to `_`
_ = var!(token)
if unquote(conditions) do
# if the given conditions are truthy, we call the Step
unquote(quoted_call)
else
# otherwise, we just return the token
token
end
end
end
end
defmodule Converter.Step do
@type t :: module
@callback call(token :: Converter.Step.t()) :: Converter.Step.t()
defmacro __using__(_opts \\ []) do
quote do
@behaviour Converter.Step
alias Converter.Token
end
end
end
defmodule Converter.MyProcess do
use Converter.StepBuilder
step Converter.Step.ParseOptions
step Converter.Step.ValidateOptions
# we'll provide the condition as a keyword
# step Converter.Step.PrepareConversion, if: token.errors == []
# step Converter.Step.ConvertImages, if: token.errors == []
# step Converter.Step.ReportResults, if: token.errors == []
# please note that `if:` is not something Elixir provides, we will have to implement this ourselves
# also, we could have named this any way we want, `if:` just seemed obvious
# step Converter.Step.ReportErrors, if: token.errors != []
step do
token.errors == [] ->
step Converter.Step.PrepareConversion
step Converter.Step.ConvertImages
step Converter.Step.ReportResults
token.errors != [] ->
step Converter.Step.ReportErrors
end
end
defmodule Converter.Token do
defstruct argv: nil,
glob: nil,
filenames: nil,
target_dir: nil,
format: nil,
errors: nil,
halted: nil,
results: nil
end
defmodule Mix.Tasks.ConvertImages do
use Mix.Task
alias Converter.MyProcess
alias Converter.Token
def run(argv) do
MyProcess.call(%Token{argv: argv})
end
end
defmodule Converter.Step.ParseOptions do
use Converter.Step
@default_glob "./image_uploads/*"
@default_target_dir "./tmp"
@default_format "jpg"
def call(%Token{argv: argv} = token) do
{opts, args, _invalid} =
OptionParser.parse(argv, switches: [target_dir: :string, format: :string])
glob = List.first(args) || @default_glob
target_dir = opts[:target_dir] || @default_target_dir
format = opts[:format] || @default_format
%Token{token | glob: glob, target_dir: target_dir, format: format}
end
end
defmodule Converter.Step.ValidateOptions do
use Converter.Step
def call(%Token{glob: glob, format: format} = token) do
filenames = Path.wildcard(glob)
errors =
[
if(Enum.empty?(filenames), do: "No images found."),
if(!Enum.member?(~w[jpg png], format), do: "Unrecognized format: #{format}")
]
|> Enum.reject(&is_nil/1)
%Token{token | errors: errors}
end
end
defmodule Converter.Step.PrepareConversion do
use Converter.Step
def call(%Token{glob: glob, target_dir: target_dir} = token) do
File.mkdir_p!(target_dir)
filenames = Path.wildcard(glob)
%Token{token | filenames: filenames}
end
end
defmodule Converter.Step.ConvertImages do
use Converter.Step
def call(token) do
results =
Enum.map(token.filenames, fn filename ->
Converter.convert_image(filename, token.target_dir, token.format)
end)
%Token{token | results: results}
end
end
defmodule Converter.Step.ReportResults do
use Converter.Step
def call(token) do
IO.puts("Wrote #{Enum.count(token.results)} images to #{token.target_dir}.")
token
end
end
defmodule Converter.Step.ReportErrors do
use Converter.Step
def call(%Token{errors: errors} = token) do
Enum.each(errors, fn error ->
IO.puts("- #{error}")
end)
token
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment