Current section

Files

Jump to
finitomata lib mix tasks finitomata_generate.ex
Raw

lib/mix/tasks/finitomata_generate.ex

defmodule Mix.Tasks.Finitomata.Generate do
@shortdoc "Generates the FSM scaffold for the `Finitomata` instance"
@moduledoc """
Mix task to generate the `Finitomata` instance scaffold.
By running `mix finitomata.generate --module MyFSM` one would be prompted
to enter the _FSM_ declartion if `ELIXIR_EDITOR` environment variable is set,
in the same way as `IEx.Helpers.open/0` does. THen the scaffold implementation
(and optionally the test for it) will be generated.
### Allowed arguments
- **`--module: :string`** __[mandatory]__ the name of the module to generate, it will be prepended
with `OtpApp.Finitomata.`
- **`--fsm-file: :string`** __[optional, default: `nil`]__ the name of the file to read the FSM description from,
the `ELIXIR_EDITOR` will be opened to enter a description otherwise
- **`--syntax: :string`** __[optional, default: `:flowchart`]__ the syntax to be used, might be
`:flowchart`, `:state_diagram`, or a module name for custom implementation
- **`--timer: :integer`** __[optional, default: `false`]__ whether to use recurrent calls in
this _FSM_ implementation
- **`--auto-terminate: :boolean`** __[optional, default: `false`]__ whether the ending states should
lead to auto-termination
- **`--listener: :string`** __[optional, default: `nil`]__ the listener implementation
- **`--impl-for: :string`** __[optional, default: `:all`]__ what callbacks should be auto-implemented
- **`--generate-test: :boolean`** __[optional, default `false`]__ whether the test should be
generated as well
- **`--callback: :string`** __[optional, default: `nil`]__ the function to be called before actual generation
### Example
```sh
mix finitomata.generate --module MyFSM --timer 1000 --auto-terminate true --generate-test true
```
"""
use Mix.Task
@finitomata_default_path "lib/finitomata"
@stub "idle --> |start| started\nstarted --> |run| running\nrunning --> |stop| stopped"
@impl Mix.Task
@doc false
def run(args) do
Mix.Task.run("compile")
Finitomata.Mix.load_app()
{opts, _pass_thru, []} =
OptionParser.parse(args,
strict: [
module: :string,
prefix: :string,
fsm_file: :string,
syntax: :string,
timer: :integer,
auto_terminate: :string,
listener: :string,
impl_for: :string,
generate_test: :boolean,
callback: :string
]
)
module = Keyword.fetch!(opts, :module)
callback =
case Keyword.fetch(opts, :callback) do
{:ok, fun} ->
fun
|> String.split(~w[& . /], trim: true)
|> Enum.reverse()
|> then(fn [a, f | m] ->
m
|> Enum.reverse()
|> Module.concat()
|> Function.capture(
String.to_existing_atom(f),
String.to_integer(a)
)
end)
_ ->
nil
end
prefix =
Keyword.get_lazy(opts, :prefix, fn ->
Mix.ProjectStack
|> GenServer.whereis()
|> case do
nil ->
[Finitomata.Implementations]
pid when is_pid(pid) ->
Mix.Project.get()
|> Module.split()
|> List.first()
|> List.wrap()
|> Kernel.++(["Finitomata"])
end
end)
{syntax, syntax_name} =
opts
|> Keyword.fetch(:syntax)
|> case do
:error -> {Finitomata.Mermaid, :flowchart}
{:ok, :flowchart} -> {Finitomata.Mermaid, :flowchart}
{:ok, :state_diagram} -> {Finitomata.PlantUML, :state_diagram}
{:ok, other} -> [other] |> Module.concat() |> then(&{&1, &1})
end
syntax_clause = "syntax: " <> inspect(syntax_name)
timer_clause =
opts
|> Keyword.fetch(:timer)
|> case do
{:ok, value} when is_integer(value) and value > 0 ->
"timer: #{value}"
{:ok, value} ->
value
|> Integer.parse()
|> then(fn
{i, _} when is_integer(i) and i > 0 -> "timer: #{i}"
:error -> "timer: true"
end)
:error ->
nil
end
auto_terminate_clause =
opts
|> Keyword.fetch(:auto_terminate)
|> case do
:error -> nil
{:ok, value} when is_boolean(value) -> "auto_terminate: #{value}"
{:ok, value} -> "auto_terminate: " <> parse_auto_terminate(value)
end
{to_implement, impl_for_clause} =
opts
|> Keyword.fetch(:impl_for)
|> case do
:error -> {[], nil}
{:ok, value} -> parse_impl_for(value)
end
test? = Keyword.get(opts, :generate_test, false)
listener_clause =
opts
|> Keyword.fetch(:listener)
|> case do
:error ->
if test?, do: "listener: :mox", else: nil
{:ok, "mox"} ->
"listener: :mox"
{:ok, ":mox"} ->
"listener: :mox"
{:ok, value} ->
if test?,
do: "listener: {:mox, " <> inspect(value) <> "}",
else: "listener: " <> inspect(value)
end
use_clause =
[
"use Finitomata",
"fsm: @fsm",
syntax_clause,
timer_clause,
auto_terminate_clause,
listener_clause,
impl_for_clause
]
|> Enum.reject(&is_nil/1)
|> Enum.join(", ")
dir = @finitomata_default_path
file =
[module]
|> Module.concat()
|> Module.split()
|> Enum.map_join("_", &Macro.underscore/1)
|> Kernel.<>(".ex")
target_file = Path.join(dir, file)
module = Module.concat(prefix ++ [module])
fsm =
case {Keyword.fetch(opts, :fsm_file), System.fetch_env("ELIXIR_EDITOR")} do
{{:ok, file}, _} ->
case File.read(file) do
{:ok, fsm_definition} ->
fsm_definition
error ->
Mix.shell().info([
[:bright, :red, "✗ #{file}", :reset],
" file could not be read (error: #{inspect(error)}). ",
[:yellow, "Stub FSM definition", :reset],
" will be generated."
])
@stub
end
{_, :error} ->
Mix.shell().info([
[:bright, :red, "✗ #{ELIXIR_EDITOR}", :reset],
" environment variable is not set. ",
[:yellow, "Stub FSM definition", :reset],
" will be generated."
])
@stub
{_, {:ok, editor}} ->
open_in_editor(@stub, editor)
end
case syntax.parse(fsm) do
{:ok, transitions} ->
Mix.Generator.copy_template(
Path.expand("fsm_template.eex", __DIR__),
target_file,
module: module,
fsm: fsm,
transitions: transitions,
use_clause: use_clause,
timer?: not is_nil(timer_clause),
auto_terminate?: not is_nil(auto_terminate_clause),
to_implement: to_implement
)
File.write!(target_file, Code.format_file!(target_file))
Mix.shell().info([
[:bright, :blue, "* #{inspect(module)}", :reset],
" has been created."
])
if test? do
:ok = Mix.Task.reenable("compile")
with {:noop, []} <- Mix.Task.run("compile", [target_file]) do
{output, _} = System.cmd("mix", ["compile", target_file])
IO.puts(output)
end
Mix.Task.run("finitomata.generate.test", ["--module", inspect(module)])
end
case callback do
fun when is_function(fun, 1) -> fun.(module)
fun when is_function(fun, 2) -> fun.(module, target_file)
_ -> :ok
end
{:error, message, _, _, {line, col}, pos} ->
Mix.shell().info([
[:bright, :red, "✗ Invalid FSM declaration", :reset],
"\n\n",
fsm,
"\n\n",
"Error: ",
[:yellow, message, :reset],
"\nLine: ",
[:yellow, line, :reset],
", Column: ",
[:yellow, col, :reset],
", Position: ",
[:yellow, pos, :reset]
])
end
end
# gracefully stolen from https://github.com/fuelen/owl/blob/v0.11.0/lib/owl/io.ex#L230
defp open_in_editor(data, elixir_editor) do
dir = System.tmp_dir!()
random_name =
9
|> :crypto.strong_rand_bytes()
|> Base.url_encode64()
|> binary_part(0, 9)
filename = "fini-#{random_name}"
tmp_file = Path.join(dir, filename)
File.write!(tmp_file, data)
elixir_editor =
if String.contains?(elixir_editor, "__FILE__") do
String.replace(elixir_editor, "__FILE__", tmp_file)
else
elixir_editor <> " " <> tmp_file
end
{_, 0} = System.shell(elixir_editor)
File.read!(tmp_file)
end
@doc false
defp parse_auto_terminate("true"), do: "true"
defp parse_auto_terminate(specified) do
specified
|> String.trim_leading("[")
|> String.trim_trailing("]")
|> String.split(~r/[,:]+/)
|> Enum.map(&String.to_atom/1)
|> inspect()
end
@doc false
defp parse_impl_for(specified) do
impls = ~w|on_enter on_exit on_failure on_fork on_start on_terminate|a
states =
specified
|> String.trim_leading("[")
|> String.trim_trailing("]")
|> String.split(~r/[,:]+/)
|> Enum.map(&String.to_atom/1)
to_implement =
case states do
[:all] -> []
[:none] -> impls
list when is_list(list) -> impls -- list
end
result =
case states do
[:all] -> :all
[:none] -> :none
[transition] when is_atom(transition) -> transition
list when is_list(list) -> list
end
|> inspect()
{to_implement, "impl_for: " <> result}
end
end