Packages
finitomata
0.40.0
0.41.0
0.40.0
0.35.0
0.34.0
0.33.0
0.32.0
0.31.1
0.30.3
0.30.2
0.30.1
0.30.0
0.29.10
0.29.9
0.29.8
0.29.7
0.29.6
0.29.5
0.29.4
0.29.3
0.29.2
0.29.1
0.29.0
0.28.1
0.28.0
0.27.1
0.27.0
0.26.4
0.26.3
0.26.2
0.26.1
0.26.0
0.25.0
0.24.4
0.24.3
0.24.2
0.24.1
0.24.0
0.23.7
0.23.6
0.23.5
0.23.4
0.23.3
0.23.2
0.23.1
0.23.0
0.22.1
0.22.0
0.21.4
0.21.3
0.21.2
0.21.1
0.21.0
0.20.2
0.20.1
0.20.0
0.19.6
0.19.5
0.19.4
0.19.3
0.19.2
0.19.1
0.19.0
0.18.4
0.18.3
0.18.2
0.18.1
0.18.0
0.17.1
0.17.0
0.16.0
0.15.1
0.15.0
0.14.6
0.14.5
0.14.4
0.14.3
0.14.2
0.14.1
0.14.0
0.13.0
0.12.1
0.12.0
0.11.3
0.11.2
0.11.1
0.11.0
0.10.0
0.9.1
0.9.0
0.8.2
0.8.1
0.8.0
0.7.2
0.7.1
0.7.0
0.6.3
0.6.2
0.6.1
0.6.0
0.5.2
0.5.1
0.5.0
0.4.0
0.3.0
0.2.0
0.1.1
0.1.0
The FSM implementation generated from PlantUML textual representation.
Current section
Files
Jump to
Current section
Files
lib/finitomata/config_builder.ex
defmodule Finitomata.ConfigBuilder do
@moduledoc false
# Compile-time helpers that turn validated `use Finitomata` options into the compiled-in
# `@__config__` map (and the generated module's `@moduledoc`).
#
# This logic used to live inline in the `use Finitomata` quote (`Finitomata.ast/2`). The
# parsing is branchy but runs exactly once, at the consuming module's compile time, so
# pulling it out of the generated code keeps the quote small (within Credo's
# complexity/quote-length budgets) and makes the configuration directly unit-testable.
#
# Everything here executes in the compile-time context of the consuming module: `env` is
# that module's `__ENV__`, and side effects such as `Mox.defmock/2` define modules
# under `env.module`. The one value resolved by the caller and passed in is `timer`,
# because its `Application.compile_env/3` default must be captured in the consumer's
# context rather than this module's.
alias Finitomata.Transition
@impls ~w|on_transition on_failure on_fork on_enter on_exit on_start on_terminate on_timer|a
@doc """
Builds the compiled-in `@__config__` map from validated `use Finitomata` `options` in the
compile-time context of `env`, with the already-resolved `timer` value.
Returns `{config, telemetria_levels}`.
"""
@spec build(keyword(), Macro.Env.t(), false | non_neg_integer()) :: {map(), keyword()}
def build(options, env, timer) do
reporter = if Code.ensure_loaded?(Mix), do: Mix.shell(), else: Logger
syntax = syntax(Keyword.fetch!(options, :syntax), reporter)
forks = Keyword.fetch!(options, :forks)
auto_terminate = Keyword.fetch!(options, :auto_terminate)
dsl = Keyword.fetch!(options, :fsm)
fsm = parse_fsm(syntax, dsl, env)
hard = hard(fsm, auto_terminate)
soft = soft(fsm)
config = %{
syntax: syntax,
fsm: fsm,
dsl: dsl,
impl_for: impl_for(Keyword.fetch!(options, :impl_for)),
forks: forks,
persistency: Keyword.fetch!(options, :persistency),
listener:
listener(
Keyword.fetch!(options, :listener),
Keyword.fetch!(options, :mox_envs),
env,
reporter
),
auto_terminate: auto_terminate,
hibernate: Keyword.fetch!(options, :hibernate),
cache_state: Keyword.fetch!(options, :cache_state),
ensure_entry: ensure_entry(Keyword.fetch!(options, :ensure_entry), fsm),
states: Transition.states(fsm),
events: Transition.events(fsm),
paths: Transition.straight_paths(fsm),
loops: Transition.loops(fsm),
entry: Transition.entry(:transition, fsm).event,
hard: hard,
hard_states: Keyword.keys(hard),
soft: soft,
soft_events: Enum.map(soft, & &1.event),
fork_states: Keyword.keys(forks),
timer: timer
}
{config, telemetria_levels(Keyword.fetch!(options, :telemetria_levels))}
end
@doc """
Builds the `@moduledoc` for the generated _FSM_ module from its `config`, appending the
module's own `existing` `@moduledoc` (if any) after a horizontal rule.
"""
@spec moduledoc(map(), term()) :: String.t()
def moduledoc(config, existing) do
own = if is_binary(existing), do: "\n---\n" <> existing, else: ""
"""
The instance of _FSM_ backed up by `Finitomata`.
- _entry event_ → `:#{config.entry}`
- _forks_ → `#{if [] == config.forks, do: "✗", else: inspect(config.forks)}`
- _persistency_ → `#{if config.persistency, do: inspect(config.persistency), else: "✗"}`
- _listener_ → `#{if config.listener, do: inspect(config.listener), else: "✗"}`
- _timer_ → `#{config.timer || "✗"}`
- _hibernate_ → `#{config.hibernate || "✗"}`
- _cache_state_ → `#{if config.cache_state, do: "✓", else: "✗"}`
## FSM representation
```#{config.syntax |> Module.split() |> List.last() |> Macro.underscore()}
#{config.syntax.lint(config.dsl)}
```
### FSM paths
```elixir
#{Enum.map_join(config.paths, "\n", &inspect/1)}
```
### FSM loops
```elixir
#{if [] != config.loops, do: Enum.map_join(config.loops, "\n", &inspect/1), else: "no loops"}
```
""" <> own
end
@spec telemetria_levels(:none | keyword()) :: keyword()
defp telemetria_levels(:none), do: []
defp telemetria_levels(some) do
case Keyword.split(some, [:all]) do
{[], levels} ->
levels
{[all: level], levels} ->
[
on_transition: level,
on_failure: level,
on_fork: level,
on_enter: level,
on_exit: level,
on_start: level,
on_terminate: level,
on_timer: level
]
|> Keyword.merge(levels)
end
end
@spec syntax(atom(), module()) :: module()
defp syntax(syntax, reporter) do
if syntax in [Finitomata.Mermaid, Finitomata.PlantUML] do
reporter.info([
[:yellow, "deprecated: ", :reset],
"using built-in modules as syntax names is deprecated, please use ",
[:blue, ":flowchart", :reset],
" and/or ",
[:blue, ":state_diagram", :reset],
" instead"
])
end
case syntax do
:flowchart -> Finitomata.Mermaid
:state_diagram -> Finitomata.PlantUML
module when is_atom(module) -> module
end
end
@spec listener(term(), atom() | [atom()], Macro.Env.t(), module()) :: term()
defp listener(listener, mox_envs, env, reporter) do
mox_envs = List.wrap(mox_envs)
def_mock = fn -> def_mock(env, reporter) end
case listener do
:mox -> if Mix.env() in mox_envs, do: def_mock.()
{:mox, listener} -> if Mix.env() in mox_envs, do: def_mock.(), else: listener
{listener, :mox} -> if Mix.env() in mox_envs, do: def_mock.(), else: listener
listener -> listener
end
end
@spec def_mock(Macro.Env.t(), module()) :: module()
case Code.ensure_compiled(Mox) do
{:error, error} ->
defp def_mock(_env, reporter) do
reporter.info([
[:yellow, "expectation: ", :reset],
"to be able to use ",
[:blue, ":mox", :reset],
" listener in tests with ",
[:blue, "`Finitomata.ExUnit`", :reset],
", please add ",
[:blue, "`{:mox, \"~> 1.0\", only: [:test]}`", :reset],
" as a dependency to your ",
[:blue, "`mix.exs`", :reset],
" project file (got: ",
[:yellow, inspect(unquote(error)), :reset],
")"
])
end
{:module, _} ->
defp def_mock(env, _reporter) do
[env.module, Mox]
|> Module.concat()
|> tap(fn mox_mod ->
Mox.defmock(mox_mod, for: Finitomata.Listener)
Code.ensure_compiled!(mox_mod)
end)
end
end
@spec impl_for(:all | :none | atom() | [atom()]) :: [atom()]
defp impl_for(impl_for) do
impl_for =
case impl_for do
:all -> @impls
:none -> []
transition when is_atom(transition) -> [transition]
list when is_list(list) -> list
end
if impl_for -- @impls != [] do
raise CompileError,
description:
"allowed `impl_for:` values are: `:all`, `:none`, or any combination of `#{inspect(@impls)}`"
end
impl_for
end
@spec parse_fsm(module(), String.t(), Macro.Env.t()) :: [Transition.t()]
defp parse_fsm(syntax, dsl, env) do
case syntax.parse(dsl, env) do
{:ok, result} ->
result
{:error, description, snippet, _context, {file, line, column}, _offset} ->
raise SyntaxError,
file: file,
line: line,
column: column,
description: description,
snippet: snippet
{:error, error} ->
raise TokenMissingError,
file: env.file,
line: env.line,
column: 0,
opening_delimiter: ~s|"""|,
description: "description is incomplete, error: #{inspect(error)}",
snippet: dsl |> String.split("\n", parts: 2) |> hd()
end
end
@spec hard([Transition.t()], boolean() | atom() | [atom()]) :: keyword(Transition.t())
defp hard(fsm, auto_terminate) do
hard =
fsm
|> Transition.determined()
|> Enum.filter(fn
{state, :__end__} ->
case auto_terminate do
^state -> true
true -> true
list when is_list(list) -> state in list
_ -> false
end
{_state, event} ->
event
|> to_string()
|> String.ends_with?("!")
end)
[Transition.hard(fsm), hard]
|> Enum.map(fn h -> h |> Enum.map(&elem(&1, 1)) |> Enum.uniq() end)
|> Enum.reduce(&Kernel.--/2)
|> unless do
raise CompileError,
description:
"transitions marked as `:hard` must be determined, non-determined found: #{inspect(Transition.hard(fsm) -- hard)}"
end
Enum.map(hard, fn {from, event} ->
tos =
fsm
|> Enum.filter(&match?(%Transition{from: ^from, event: ^event}, &1))
|> Enum.map(& &1.to)
{from, %Transition{from: from, event: event, to: tos}}
end)
end
@spec soft([Transition.t()]) :: [Transition.t()]
defp soft(fsm) do
Enum.filter(fsm, fn %Transition{event: event} ->
event
|> to_string()
|> String.ends_with?("?")
end)
end
@spec ensure_entry(boolean() | [atom()], [Transition.t()]) :: [atom()]
defp ensure_entry(ensure_entry, fsm) do
case ensure_entry do
list when is_list(list) -> list
true -> [Transition.entry(fsm)]
_ -> []
end
end
end