diff --git a/.formatter.exs b/.formatter.exs index d304ff3..f6298b4 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,3 +1,8 @@ +export_locals_without_parens = [defbypass: 2, deftrans: 2] + [ - inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] + plugins: [ExFSM.FormatterPlugin], + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], + locals_without_parens: export_locals_without_parens, + export: [locals_without_parens: export_locals_without_parens] ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0644c47 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,58 @@ +name: CI + +on: + pull_request: + branches: + - master + push: + branches: + - master + +jobs: + mix-test: + runs-on: ubuntu-22.04 + + strategy: + fail-fast: false + matrix: + include: + - elixir: "1.15" + otp: "25" + - elixir: "1.17" + otp: "27" + - elixir: "1.18" + otp: "27" + - elixir: "1.19" + otp: "28" + lint: lint + + steps: + - uses: actions/checkout@v5 + + - uses: erlef/setup-beam@v1 + with: + otp-version: ${{matrix.otp}} + elixir-version: ${{matrix.elixir}} + + - uses: actions/cache@v4 + with: + path: | + deps + _build + key: deps-${{ runner.os }}-${{matrix.otp}}-${{matrix.elixir}}-${{ hashFiles('**/mix.lock') }} + restore-keys: deps-${{ runner.os }}-${{matrix.otp}}-${{matrix.elixir}} + + - run: mix deps.get + + - run: mix format --check-formatted + if: ${{ matrix.lint }} + + - run: mix deps.unlock --check-unused + if: ${{ matrix.lint }} + + - run: mix deps.compile + + - run: mix compile --warnings-as-errors + if: ${{ matrix.lint }} + + - run: mix test diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index ae52b5e..0000000 --- a/.travis.yml +++ /dev/null @@ -1,24 +0,0 @@ -language: elixir -elixir: - - 1.3.4 - - 1.4.5 - - 1.5.3 - - 1.6.3 -otp_release: - - 18.3 - - 19.3 - - 20.3 -sudo: false -script: mix test -matrix: - exclude: - - elixir: 1.3.4 - otp_release: 20.3 - - elixir: 1.5.3 - otp_release: 18.3 - - elixir: 1.5.3 - otp_release: 19.3 - - elixir: 1.6.3 - otp_release: 18.3 - - elixir: 1.6.3 - otp_release: 19.3 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3b564b1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,29 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## 1.0.0 - 2026/07/27 + +### Added + +- Macros `deftrans` and `defbypass` are now usable without parenthesis when + importing the dependency in your `.formatter.exs` file. +- Macros `deftrans` and `defbypass` now supports `when` clause. +- Sigil `ExFSM.sigil_FSM/2` and its formatter plugin `ExFSM.FormatterPlugin`. +- `use ExFSM` now accepts the options `:with_action_name_macros` and + `:with_action_names_guard`. + +### Fixed + +- Detect output states from transition with multiple function heads correctly + which fixes the returned output state of the `fsm/0` function. + +### Changed + +- **BREAKING**: the reserved Elixir `@doc` attribute used to add documentation + transitions and bypasses was removed as it emitted warnings when used on + a transition or bypasss with several heads. Instead use the `@transition_doc` + for transition and the `@bypass_doc` for bypasses. diff --git a/README.md b/README.md index 6085f8e..269a6c8 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,42 @@ # ExFSM # -[![Build Status](https://travis-ci.org/kbrw/exfsm.svg?branch=master)](https://travis-ci.org/kbrw/exfsm) +[![Build Status](https://github.com/kbrw/exfsm/actions/workflows/.github/workflows/ci.yml/badge.svg)](https://github.com/kbrw/exfsm/actions/workflows/ci.yml) -Simple elixir library to define composable FSM as function +Simple Elixir library to define composable [mealy +FSM](https://en.wikipedia.org/wiki/Mealy_machine) as function (not related at all with `:gen_fsm`, no state/process management). -- define FSM with handler modules defining each transition as a simple function but using a +```elixir +defmodule Ligh do + use ExFSM + + deftrans on({:off, _}, state), do: {:next_state, :off, state} + deftrans off({:on, _}, state), do: {:next_state, :on, state} +end + +defmodule Light.State do + @enforced_keys [:state] + defstruct @enforced_keys +end + +defimpl ExFSM.Machine.State, for: Light.State do + def state_name(state), do: state.state + def set_state_name(state, state_name), do: struct(state, state: state_name) + def handlers(_staet), do: [Light] +end + +{:next_state, %_{state: :off} = state} = ExFSM.Machine.event({:on, nil}, %Light{state: :off}) +{:next_state, %_{state: :on} = state} = ExFSM.Machine.event({:off, nil}, %Light{state: :on}) +{:error, :illegal_action} = ExFSM.Machine.event({:on, nil}, %Light{state: :on}) +``` + +- define an FSM with handler modules defining each transition as a simple function but using a macro `deftrans` which creates a function `fsm` returning the fsm transition map for this handler module. - `deftrans` has the same semantic as [erlang in memory FSM gen_fsm](http://www.erlang.org/doc/man/gen_fsm.html) -- combine together multiple fsm handlers to create a "meta" FSM. +- combine together multiple FSM handlers to create a "meta" FSM. - send event with the function `event` which simply find the right handler, execute the handler function. - ## Usage ## See in [in code documentation](http://hexdocs.pm/exfsm) diff --git a/lib/dummy_fsm.ex b/lib/dummy_fsm.ex new file mode 100644 index 0000000..8e9a209 --- /dev/null +++ b/lib/dummy_fsm.ex @@ -0,0 +1,41 @@ +defmodule ExFSM.Dummy.FSM.Instance do + @moduledoc false + + @type t :: %__MODULE__{ + type: atom(), + state: atom() + } + + @behaviour Access + + defstruct type: nil, state: nil + + @impl Access + defdelegate fetch(obj, key), to: Map + + @impl Access + defdelegate get_and_update(data, key, function), to: Map + + @impl Access + defdelegate pop(data, key), to: Map +end + +defimpl ExFSM.Machine.State, for: ExFSM.Dummy.FSM.Instance do + def state_name(instance), do: instance.state + def set_state_name(instance, state_name), do: Map.put(instance, :state, state_name) + def handlers(_state), do: [ExFSM.Dummy.FSM] +end + +defmodule ExFSM.Dummy.FSM do + @moduledoc false + + use ExFSM + + ~FSM""" + opened -- close -> closed + closed -- open -> opened + """ + + deftrans opened({:close, _}, state), do: {:next_state, :closed, state} + deftrans closed({:open, _}, state), do: {:next_state, :opened, state} +end diff --git a/lib/exfsm.ex b/lib/exfsm.ex index ff7c497..1831792 100644 --- a/lib/exfsm.ex +++ b/lib/exfsm.ex @@ -1,67 +1,94 @@ defmodule ExFSM do - @type fsm_spec :: %{ - {state_name :: atom, event_name :: atom} => - {exfsm_module :: atom, [dest_statename :: atom]} - } @moduledoc """ - After `use ExFSM` : define FSM transition handler with `deftrans - fromstate({action_name,params},state)`. A function `fsm` will be created - returning a map of the `fsm_spec` describing the fsm. + Module to define an FSM. + + After `use ExFSM`: define FSM transition handler with `deftrans + fromstate({action_name,params},state)`. A function `fsm/0` will be created + returning a map of the `t:fsm_spec/0` describing the FSM. Destination states are found with AST introspection, if the `{:next_state,xxx,xxx}` is defined outside the `deftrans/2` function, you - have to define them manually defining a `@to` attribute. + have to define them manually via a `@to` attribute. + + When used, it accepts the following options: + + * `:with_action_name_macros` - adds macros for each FSM's action bearing its + name which returns the action's name. Macros are added via the + `@before_compile` hook. - For instance : + * `:with_action_names_guard` - adds a `is_action/1` guard which checks if the + given term is an action of the FSM. The guard is added via the + `@before_compile` hook. + + For instance: iex> defmodule Elixir.Door do ...> use ExFSM - ...> @moduledoc false ...> - ...> @doc "Close to open" + ...> @transition_doc "Close to open" ...> @to [:opened] ...> deftrans closed({:open, _}, s) do ...> {:next_state, :opened, s} ...> end ...> - ...> @doc "Close to close" + ...> @transition_doc "Close to close" ...> deftrans closed({:close, _}, s) do ...> {:next_state, :closed, s} ...> end ...> + ...> @transition_doc "Close to close" ...> deftrans closed({:else, _}, s) do ...> {:next_state, :closed, s} ...> end ...> - ...> @doc "Open to open" + ...> @transition_doc "Open to open" ...> deftrans opened({:open, _}, s) do ...> {:next_state, :opened, s} ...> end ...> - ...> @doc "Open to close" + ...> @transition_doc "Open to close" ...> @to [:closed] ...> deftrans opened({:close, _}, s) do ...> {:next_state, :closed, s} ...> end ...> + ...> @transition_doc "Open to open" ...> deftrans opened({:else, _}, s) do ...> {:next_state, :opened, s} ...> end ...> end - ...> Door.fsm + ...> Door.fsm() %{{:closed, :close} => {Door, [:closed]}, {:closed, :else} => {Door, [:closed]}, {:closed, :open} => {Door, [:opened]}, {:opened, :close} => {Door, [:closed]}, {:opened, :else} => {Door, [:opened]}, {:opened, :open} => {Door, [:opened]}} - iex> Door.docs - %{{:transition_doc, :closed, :close} => "Close to close", - {:transition_doc, :closed, :else} => nil, + iex> Door.docs() + %{ + {:transition_doc, :closed, :close} => "Close to close", + {:transition_doc, :closed, :else} => "Close to close", {:transition_doc, :closed, :open} => "Close to open", {:transition_doc, :opened, :close} => "Open to close", - {:transition_doc, :opened, :else} => nil, - {:transition_doc, :opened, :open} => "Open to open"} + {:transition_doc, :opened, :else} => "Open to open", + {:transition_doc, :opened, :open} => "Open to open" + } """ - defmacro __using__(_opts) do + @typedoc "A module which `use ExFSM` and defines an FSM." + @type handler :: module() + @type state_name :: atom() + + @type event :: {action_name, event_payload} + @type action_name :: atom() + @type event_payload :: term() + + @type fsm_spec :: %{ + {state_name(), event_name :: action_name()} => + {exfsm_module :: handler(), [dest_statename :: state_name()]} + } + + defmacro __using__(opts) do + with_action_name_macros? = Keyword.get(opts, :with_action_name_macros, false) + with_action_names_guard? = Keyword.get(opts, :with_action_names_guard, false) + quote do import ExFSM @fsm %{} @@ -69,38 +96,79 @@ defmodule ExFSM do @docs %{} @to nil @before_compile ExFSM + + if unquote(with_action_name_macros?), + do: @before_compile({unquote(__MODULE__), :__build_action_name_macros__}) + + if unquote(with_action_names_guard?), + do: @before_compile({unquote(__MODULE__), :__build_action_names_guard__}) end end - defmacro __before_compile__(_env) do + defmacro __before_compile__(env) do + fsm = Macro.escape(Module.get_attribute(env.module, fsm_attribute_name())) + quote do - def fsm, do: @fsm + def fsm, do: unquote(fsm) def event_bypasses, do: @bypasses def docs, do: @docs end end @doc """ - Define a function of type `transition` describing a state and its transition. + Defines a transition function. + The function name is the state name, the transition is the first argument. A state object can be modified and is the second argument. deftrans opened({:close_door,_params},state) do {:next_state,:closed,state} end + + The transition function has the following signature: + ```elixir + (event, state -> + {:next_state, state_name, state} + | {:next_state, state_name, state, timeout} + | term() + when event: ExFSM.event(), + state_name: ExFSM.state_name(), + state: ExFSM.Machine.State.t(), + timeout: non_neg_integer() | :infinity + ``` """ - @type transition :: ({event_name :: atom, event_param :: any}, state :: any -> - {:next_state, event_name :: atom, state :: any}) - defmacro deftrans({state, _meta, [{trans, _param} | _rest]} = signature, body_block) do + defmacro deftrans(signature, body_block) do + {state, transition} = + case signature do + {:when, _, _} -> + {:when, _, [{state, _, [{transition, _} | _]} | _]} = signature + + {state, transition} + + _ -> + {state, _, [{transition, _} | _]} = signature + + {state, transition} + end + quote do - @fsm Map.put( + output_states = + if is_list(@to), + do: @to, + else: unquote(Enum.uniq(find_nextstates(body_block[:do]))) + + @fsm Map.update( @fsm, - {unquote(state), unquote(trans)}, - {__MODULE__, @to || unquote(Enum.uniq(find_nextstates(body_block[:do])))} + {unquote(state), unquote(transition)}, + {__MODULE__, output_states}, + fn {module, prev_output_state} -> + {module, Enum.uniq(prev_output_state ++ output_states)} + end ) - doc = Module.get_attribute(__MODULE__, :doc) - @docs Map.put(@docs, {:transition_doc, unquote(state), unquote(trans)}, doc) + doc = Module.get_attribute(__MODULE__, :transition_doc) + @docs Map.put(@docs, {:transition_doc, unquote(state), unquote(transition)}, doc) def unquote(signature), do: unquote(body_block[:do]) + @transition_doc nil @to nil end end @@ -111,28 +179,195 @@ defmodule ExFSM do defp find_nextstates(asts) when is_list(asts), do: Enum.flat_map(asts, &find_nextstates/1) defp find_nextstates(_), do: [] - defmacro defbypass({event, _meta, _args} = signature, body_block) do + defmacro defbypass(signature, body_block) do + event = + case signature do + {:when, _, _} -> + {:when, _, [{event, _, _} | _]} = signature + + event + + _ -> + {event, _, _} = signature + + event + end + quote do @bypasses Map.put(@bypasses, unquote(event), __MODULE__) - doc = Module.get_attribute(__MODULE__, :doc) + doc = Module.get_attribute(__MODULE__, :bypass_doc) @docs Map.put(@docs, {:event_doc, unquote(event)}, doc) def unquote(signature), do: unquote(body_block[:do]) + @bypass_doc nil end end + + defp attribute_name, do: :fsm_description + defp attribute_description_line, do: :"#{attribute_name()}_line" + defp fsm_attribute_name, do: :fsm + + defmacro __build_action_name_macros__(env) do + Module.get_attribute(env.module, fsm_attribute_name()) + |> MapSet.new(fn {{_state_in, action_name}, {_module, _state_outs}} -> action_name end) + |> Enum.map(fn action_name -> + quote do + @doc "Returns `#{unquote(inspect(action_name))}`." + defmacro unquote(Macro.var(action_name, __MODULE__)), do: unquote(action_name) + end + end) + end + + defmacro __build_action_names_guard__(env) do + action_names = + Module.get_attribute(env.module, fsm_attribute_name()) + |> MapSet.new(fn {{_state_in, action_name}, {_module, _state_outs}} -> action_name end) + |> Enum.to_list() + + handler_module = Enum.join(Module.split(env.module), ".") + + quote do + @doc """ + Returns `true` if `term` is an action used in a transition of the + `#{unquote(handler_module)}` module, `false` otherwise. + """ + defguard is_action(term) when term in unquote(action_names) + end + end + + @doc ~S( + A sigil to enumerate transitions. + + When an FSM grows, it helps to quickly get an overview of the available + transition. This sigil's purpose is only for documentation purpose: + + ~FSM""" + on -- off -> [off, broken] + on -- on -> broken + off -- on -> [broken, on] + """ + + We used to add every transaction by hand to the FSM's module but it kept + getting out of sync. Thanks to this sigil, a compiler error is emitted if the + sigil's content becomes out of sync. + + A `mix format` plugin for this sigil is also available, see + `ExFSM.FormatterPlugin`. + ) + defmacro sigil_FSM(arg, []) do + {:<<>>, meta, ["" <> str]} = arg + + ast = + case Code.string_to_quoted!("(#{str})", meta) do + transitions when is_list(transitions) -> + transitions + + {:__block__, [], []} -> + [] + end + + _ = Module.put_attribute(__CALLER__.module, attribute_name(), ast) + _ = Module.put_attribute(__CALLER__.module, attribute_description_line(), __CALLER__.line) + + quote do + @before_compile {unquote(__MODULE__), :__validate_description__} + end + end + + def __validate_description__(env) do + ast = Module.get_attribute(env.module, attribute_name()) + + description = + MapSet.new(ast, fn + {:->, _meta0, + [ + [{:--, _meta1, [{state_in, _meta2, _con2}, {event, _meta3, _cont3}]}], + {state_out, _meta4, _con4} + ]} -> + {state_in, event, MapSet.new([state_out])} + + {:->, _meta0, + [[{:--, _meta1, [{state_in, _meta2, _con2}, {event, _meta3, _cont3}]}], states]} + when is_list(states) -> + state_outs = MapSet.new(states, fn {state, _meta4, _cont4} -> state end) + {state_in, event, state_outs} + end) + + fsm = + Module.get_attribute(env.module, fsm_attribute_name()) + |> MapSet.new(fn {{state_in, event}, {_module, state_outs}} -> + {state_in, event, MapSet.new(state_outs)} + end) + + missing_descriptions = MapSet.difference(fsm, description) + missing_transitions = MapSet.difference(description, fsm) + + msg = + if not Enum.empty?(missing_descriptions) do + """ + The description is missing the following transitions: + #{format_transitions(missing_descriptions)} + """ + else + "" + end + + msg2 = + if not Enum.empty?(missing_transitions) do + """ + The transitions are missing the following description: + #{format_transitions(missing_transitions)} + """ + else + "" + end + + case msg <> msg2 do + "" -> + :ok + + error -> + line = Module.get_attribute(env.module, attribute_description_line()) + raise(CompileError, file: env.file, line: line, description: "\n#{error}") + end + end + + defp format_transitions(transitions) do + state_in_padding = + Enum.max( + Enum.map(transitions, fn {state_in, _, _} -> String.length(to_string(state_in)) end) + ) + + event_padding = + Enum.max(Enum.map(transitions, fn {_, event, _} -> String.length(to_string(event)) end)) + + fragment = + Enum.map_join(transitions, "\n", fn {state_in, event, state_outs} -> + state_in = String.pad_trailing(to_string(state_in), state_in_padding) + event = String.pad_trailing(to_string(event), event_padding) + + state_out = + case Enum.to_list(state_outs) do + [state_out] -> to_string(state_out) + xs -> "[#{Enum.join(xs, ", ")}]" + end + + "\t#{state_in} -- #{event} -> #{state_out}" + end) + + fragment + end end defmodule ExFSM.Machine do @moduledoc """ - Module to simply use FSMs defined with ExFSM: + Module to simply use FSMs defined with `ExFSM`: - - `ExFSM.Machine.fsm/1` merge fsm from multiple handlers (see `ExFSM` to see - how to define one). - - `ExFSM.Machine.event_bypasses/1` merge bypasses from multiple handlers (see - `ExFSM` to see how to define one). + - `ExFSM.Machine.fsm/1` merges FSMs from multiple handlers. + - `ExFSM.Machine.event_bypasses/1` merges bypasses from multiple handlers. - `ExFSM.Machine.event/2` allows you to execute the correct handler from a state and action - Define a structure implementing `ExFSM.Machine.State` in order to define how + Defines a structure implementing `ExFSM.Machine.State` in order to define how to extract handlers and state_name from state, and how to apply state_name change. Then use `ExFSM.Machine.event/2` in order to execute transition. @@ -142,9 +377,9 @@ defmodule ExFSM.Machine do ...> end ...> defmodule Elixir.Door2 do ...> use ExFSM - ...> @doc "allow multiple closes" + ...> @bypass_doc "allow multiple closes" ...> defbypass close_door(_,s), do: {:keep_state,Map.put(s,:doubleclosed,true)} - ...> @doc "standard door open" + ...> @transition_doc "standard door open" ...> deftrans opened({:close_door,_},s) do {:next_state,:closed,s} end ...> end ...> ExFSM.Machine.fsm([Door1,Door2]) @@ -173,16 +408,22 @@ defmodule ExFSM.Machine do """ defprotocol State do - @doc "retrieve current state handlers from state object, return [Handler1,Handler2]" + @typedoc "All types that implement this protocol." + @type t :: term() + + @doc "Gets `state`'s FSM handler modules." + @spec handlers(t()) :: [ExFSM.handler()] def handlers(state) - @doc "retrieve current state name from state object" + @doc "Gets `state`'s state_name." + @spec state_name(t()) :: ExFSM.state_name() def state_name(state) - @doc "set new state name" + @doc "Sets `state`'s state_name with `state_name`." + @spec set_state_name(t(), ExFSM.state_name()) :: t() def set_state_name(state, state_name) end - @doc "return the FSM as a map of transitions %{{state,action}=>{handler,[dest_states]}} based on handlers" - @spec fsm([exfsm_module :: atom]) :: ExFSM.fsm_spec() + @doc "Returns the FSM as a map of transitions `%{{state_name, action} => {handler, [dest_states]}}` based on handlers" + @spec fsm([exfsm_module :: ExFSM.handler()]) :: ExFSM.fsm_spec() def fsm(handlers) when is_list(handlers), do: handlers |> Enum.map(& &1.fsm()) |> Enum.concat() |> Enum.into(%{}) @@ -193,9 +434,9 @@ defmodule ExFSM.Machine do def event_bypasses(state), do: event_bypasses(State.handlers(state)) - @doc "find the ExFSM Module from the list `handlers` implementing the event `action` from `state_name`" - @spec find_handler({state_name :: atom, event_name :: atom}, [exfsm_module :: atom]) :: - exfsm_module :: atom + @doc "Finds the ExFSM module from the list `handlers` implementing the event `action` from `state_name`" + @spec find_handler({ExFSM.state_name(), ExFSM.action_name()}, [ExFSM.handler()]) :: + ExFSM.handler() def find_handler({state_name, action}, handlers) when is_list(handlers) do case Map.get(fsm(handlers), {state_name, action}) do {handler, _} -> handler @@ -203,7 +444,8 @@ defmodule ExFSM.Machine do end end - @doc "same as `find_handler/2` but using a 'meta' state implementing `ExFSM.Machine.State`" + @doc "Same as `find_handler/2` but uses a `t:ExFSM.Machine.State.t/0` from which state_name and handlers are retrieved." + @spec find_handler({ExFSM.Machine.State.t(), ExFSM.action_name()}) :: ExFSM.handler() def find_handler({state, action}), do: find_handler({State.state_name(state), action}, State.handlers(state)) @@ -227,13 +469,19 @@ defmodule ExFSM.Machine do end end - @doc "Meta application of the transition function, using `find_handler/2` to find the module implementing it." - @type meta_event_reply :: + @doc """ + Executes a transition from `state`'s state_name with the given `event`. + + If no handler module can handle the transition, the event function attempt to + retrieve an handler which can handle a bypass with the given action. If no + handler module can handle the bypass, `{:error, :illegal_action}` is + returned. + """ + @spec event(ExFSM.Machine.State.t(), ExFSM.event()) :: {:next_state, ExFSM.Machine.State.t()} - | {:next_state, ExFSM.Machine.State.t(), timeout :: integer} + | {:next_state, ExFSM.Machine.State.t(), timeout :: non_neg_integer() | :infinity} | {:error, :illegal_action} - @spec event(ExFSM.Machine.State.t(), {event_name :: atom, event_params :: any}) :: - meta_event_reply + | term() def event(state, {action, params}) do case find_handler({state, action}) do nil -> @@ -271,7 +519,7 @@ defmodule ExFSM.Machine do end end - @spec available_actions(ExFSM.Machine.State.t()) :: [action_name :: atom] + @spec available_actions(ExFSM.Machine.State.t()) :: [ExFSM.action_name()] def available_actions(state) do fsm_actions = ExFSM.Machine.fsm(state) @@ -282,7 +530,7 @@ defmodule ExFSM.Machine do Enum.uniq(fsm_actions ++ bypasses_actions) end - @spec action_available?(ExFSM.Machine.State.t(), action_name :: atom) :: boolean + @spec action_available?(ExFSM.Machine.State.t(), ExFSM.action_name()) :: boolean() def action_available?(state, action) do action in available_actions(state) end diff --git a/lib/exfsm/formatter_plugin.ex b/lib/exfsm/formatter_plugin.ex new file mode 100644 index 0000000..47a6a0d --- /dev/null +++ b/lib/exfsm/formatter_plugin.ex @@ -0,0 +1,55 @@ +defmodule ExFSM.FormatterPlugin do + @moduledoc """ + A `mix format` plugin registering the `~FSM` sigil used to describe an FSM + (see `ExFSM.sigil_FSM/2`). + + The formatter aligns the input states on the same column and does the same to + the actions and the output states by adding padding to the separators `--` and + `->`. + + Enable it in `.formatter.exs`: + + [ + plugins: [ExFSM.FormatterPlugin], + # ... + ] + """ + + @behaviour Mix.Tasks.Format + + @impl Mix.Tasks.Format + def features(_opts), do: [sigils: [:FSM]] + + @impl Mix.Tasks.Format + def format(contents, _opts) do + rows = + contents + |> String.split("\n") + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.map(&parse/1) + + case rows do + [] -> + "" + + rows -> + state_pad = rows |> Enum.map(&String.length(elem(&1, 0))) |> Enum.max() + event_pad = rows |> Enum.map(&String.length(elem(&1, 1))) |> Enum.max() + + rows + |> Enum.map_join("\n", fn {state_in, event, state_out} -> + state_in = String.pad_trailing(state_in, state_pad) + event = String.pad_trailing(event, event_pad) + "#{state_in} -- #{event} -> #{state_out}" + end) + |> Kernel.<>("\n") + end + end + + defp parse(line) do + [lhs, state_out] = String.split(line, "->", parts: 2) + [state_in, event] = String.split(lhs, "--", parts: 2) + {String.trim(state_in), String.trim(event), String.trim(state_out)} + end +end diff --git a/mix.exs b/mix.exs index 648a47d..11a9c50 100644 --- a/mix.exs +++ b/mix.exs @@ -1,21 +1,25 @@ defmodule ExFSM.Mixfile do use Mix.Project + @version "1.0.0" + def project do [ app: :exfsm, - version: "0.1.6", + version: @version, elixir: if Mix.env() == :dev do ">= 1.15.0" else ">= 1.11.0" end, + elixirc_paths: elixirc_paths(Mix.env()), build_embedded: Mix.env() == :prod, consolidate_protocols: Mix.env() != :test, docs: [ main: "ExFSM", - source_url: "https://github.com/kbrw/exfsm/tree/v0.1.6", + extras: ["CHANGELOG.md"], + source_url: "https://github.com/kbrw/exfsm/tree/v#{@version}", source_ref: "master" ], description: """ @@ -35,4 +39,7 @@ defmodule ExFSM.Mixfile do ] ] end + + def elixirc_paths(:test), do: ["lib", "test/fsm"] + def elixirc_paths(_), do: ["lib"] end diff --git a/mix.lock b/mix.lock index d452c8e..09fcb2c 100644 --- a/mix.lock +++ b/mix.lock @@ -1,9 +1,8 @@ %{ - "earmark": {:hex, :earmark, "1.2.2", "f718159d6b65068e8daeef709ccddae5f7fdc770707d82e7d126f584cd925b74", [:mix], [], "hexpm", "59514c4a207f9f25c5252e09974367718554b6a0f41fe39f7dc232168f9cb309"}, - "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, - "ex_doc": {:hex, :ex_doc, "0.38.4", "ab48dff7a8af84226bf23baddcdda329f467255d924380a0cf0cee97bb9a9ede", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "f7b62346408a83911c2580154e35613eb314e0278aeea72ed7fedef9c1f165b2"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.46", "67607a0532e810c6f630a515c548d0b24949643f168cc556303bee4cf96105c7", [:mix], [], "hexpm", "9c44636e8a1c68c62f526b2dcd85d941dbbcee7ab82cf64ba06ce28bef8e89f5"}, + "ex_doc": {:hex, :ex_doc, "0.40.3", "4a972ffe64bc07dc605af487e98fc19b72a4185f55ca031b94c0552d6071c1d9", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2756e357742fecd9749b489b85d67c9ce99c465f2e75728d9e6dc8d704b973de"}, + "makeup": {:hex, :makeup, "1.2.2", "882d46dc0905e9ff7abf2aab61a7e6b3dcc555533977d8a23b06019e6c89ac94", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9a1a24e5b343b8ae16abea0822c10a6f75da27af7fa802ada5251f7579bfccfa"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, } diff --git a/test/exfsm/formatter_plugin_test.exs b/test/exfsm/formatter_plugin_test.exs new file mode 100644 index 0000000..99e396f --- /dev/null +++ b/test/exfsm/formatter_plugin_test.exs @@ -0,0 +1,27 @@ +defmodule ExFSM.FormatterPluginTest do + use ExUnit.Case + + describe "ExFSM.FormatterPlugin" do + test "registers the ~FSM sigil" do + assert ExFSM.FormatterPlugin.features([]) == [sigils: [:FSM]] + end + + test "aligns transition columns, including multi-destination states" do + input = """ + on -- off -> [off, broken] + on -- on -> broken + off -- on -> [broken, on] + """ + + assert ExFSM.FormatterPlugin.format(input, []) == """ + on -- off -> [off, broken] + on -- on -> broken + off -- on -> [broken, on] + """ + end + + test "returns an empty string for blank content" do + assert ExFSM.FormatterPlugin.format("\n \n", []) == "" + end + end +end diff --git a/test/exfsm_test.exs b/test/exfsm_test.exs index a2bc140..04cbc5e 100644 --- a/test/exfsm_test.exs +++ b/test/exfsm_test.exs @@ -2,4 +2,41 @@ defmodule ExFSMTest do use ExUnit.Case doctest ExFSM doctest ExFSM.Machine + + describe "ExFSM" do + test "should perform transitions" do + state = %ExFSM.Test.FSM.Light.State{max_usage: 1, usage: 0, state: :off} + + {:next_state, state} = ExFSM.Machine.event(state, {:on, nil}) + assert state.state == :on + {:next_state, state} = ExFSM.Machine.event(state, {:off, :with_hand}) + assert state.state == :off + {:next_state, state} = ExFSM.Machine.event(state, {:on, nil}) + assert state.state == :broken + end + + test "should error out on an unavailable transition" do + state = %ExFSM.Test.FSM.Light.State{max_usage: 1, usage: 0, state: :off} + + assert ExFSM.Machine.event(state, {:off, nil}) == {:error, :illegal_action} + end + + test "should return error tuple" do + state = %ExFSM.Test.FSM.Light.State{max_usage: 1, usage: 0, state: :on} + + assert ExFSM.Machine.event(state, {:on, nil}) == {:error, :already_on} + end + + test "should ennumerate every possible output state for a given state and action" do + transitions = ExFSM.Test.FSM.Light.fsm() + + assert Map.fetch!(transitions, {:off, :on}) == {ExFSM.Test.FSM.Light, [:broken, :on]} + end + + test "shouldn't ennumerate error as a possible output state" do + transitions = ExFSM.Test.FSM.Light.fsm() + + assert Map.fetch!(transitions, {:on, :on}) == {ExFSM.Test.FSM.Light, [:broken]} + end + end end diff --git a/test/fsm/light.ex b/test/fsm/light.ex new file mode 100644 index 0000000..9888902 --- /dev/null +++ b/test/fsm/light.ex @@ -0,0 +1,51 @@ +defmodule ExFSM.Test.FSM.Light.State do + @type t :: %__MODULE__{ + max_usage: non_neg_integer(), + usage: non_neg_integer(), + state: atom() + } + + @enforced_keys [:max_usage, :state, :usage] + defstruct @enforced_keys +end + +defimpl ExFSM.Machine.State, for: ExFSM.Test.FSM.Light.State do + def state_name(instance), do: instance.state + def set_state_name(instance, state_name), do: struct(instance, state: state_name) + def handlers(_state), do: [ExFSM.Test.FSM.Light] +end + +defmodule ExFSM.Test.FSM.Light do + use ExFSM + + ~FSM""" + on -- off -> [off, broken] + on -- on -> broken + off -- on -> [broken, on] + """ + + deftrans on({:off, :with_hand}, state) do + {:next_state, :off, state} + end + + deftrans on({:off, :with_water}, state) do + {:next_state, :broken, state} + end + + deftrans on({:on, :with_force}, state) do + {:next_state, :broken, state} + end + + deftrans on({:on, _}, _) do + {:error, :already_on} + end + + deftrans off({:on, _}, state) when state.usage >= state.max_usage do + {:next_state, :broken, state} + end + + deftrans off({:on, _}, state) do + state = Map.update!(state, :usage, &Kernel.+(&1, 1)) + {:next_state, :on, state} + end +end