From 4aad2efa6aa485c34ce9731683db80ee4a4c786a Mon Sep 17 00:00:00 2001 From: Krapaince Date: Sat, 25 Jul 2026 08:56:47 +0200 Subject: [PATCH 01/15] fix: clear Elixir's compiler v1.20 warnings --- lib/dummy_fsm.ex | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 lib/dummy_fsm.ex diff --git a/lib/dummy_fsm.ex b/lib/dummy_fsm.ex new file mode 100644 index 0000000..0d59c24 --- /dev/null +++ b/lib/dummy_fsm.ex @@ -0,0 +1,36 @@ +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 + + deftrans opened({:close, _}, state), do: {:next_state, :closed, state} + deftrans closed({:open, _}, state), do: {:next_state, :opened, state} +end From 6cd1723d94d2e66d0efe5461cb6ccf89f6d05492 Mon Sep 17 00:00:00 2001 From: Krapaince Date: Sat, 25 Jul 2026 09:08:55 +0200 Subject: [PATCH 02/15] feat(format): make macro usable without parens --- .formatter.exs | 6 +++++- CHANGELOG.md | 13 +++++++++++++ mix.exs | 1 + 3 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md diff --git a/.formatter.exs b/.formatter.exs index d304ff3..af85b2f 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,3 +1,7 @@ +export_locals_without_parens = [defbypass: 2, deftrans: 2] + [ - inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] + 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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4c19ba5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# 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). + +## Unreleased + +### Added + +- Macros `deftrans` and `defbypass` are now usable without parenthesis when + importing the dependency in your `.formatter.exs` file. diff --git a/mix.exs b/mix.exs index 648a47d..278670f 100644 --- a/mix.exs +++ b/mix.exs @@ -15,6 +15,7 @@ defmodule ExFSM.Mixfile do consolidate_protocols: Mix.env() != :test, docs: [ main: "ExFSM", + extras: ["CHANGELOG.md"], source_url: "https://github.com/kbrw/exfsm/tree/v0.1.6", source_ref: "master" ], From 9ce9f10faf331bbb5955a546e79c7088710d10a9 Mon Sep 17 00:00:00 2001 From: Krapaince Date: Mon, 27 Jul 2026 11:22:34 +0200 Subject: [PATCH 03/15] feat: add when clause support for macros --- CHANGELOG.md | 1 + lib/exfsm.ex | 34 ++++++++++++++++++++++++++++++---- mix.exs | 4 ++++ test/exfsm_test.exs | 13 +++++++++++++ test/fsm/light.ex | 32 ++++++++++++++++++++++++++++++++ 5 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 test/fsm/light.ex diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c19ba5..03667f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,3 +11,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. diff --git a/lib/exfsm.ex b/lib/exfsm.ex index ff7c497..581e9e0 100644 --- a/lib/exfsm.ex +++ b/lib/exfsm.ex @@ -91,15 +91,28 @@ defmodule ExFSM do """ @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( @fsm, - {unquote(state), unquote(trans)}, + {unquote(state), unquote(transition)}, {__MODULE__, @to || unquote(Enum.uniq(find_nextstates(body_block[:do])))} ) doc = Module.get_attribute(__MODULE__, :doc) - @docs Map.put(@docs, {:transition_doc, unquote(state), unquote(trans)}, doc) + @docs Map.put(@docs, {:transition_doc, unquote(state), unquote(transition)}, doc) def unquote(signature), do: unquote(body_block[:do]) @to nil end @@ -111,7 +124,20 @@ 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) diff --git a/mix.exs b/mix.exs index 278670f..3f46442 100644 --- a/mix.exs +++ b/mix.exs @@ -11,6 +11,7 @@ defmodule ExFSM.Mixfile do else ">= 1.11.0" end, + elixirc_paths: elixirc_paths(Mix.env()), build_embedded: Mix.env() == :prod, consolidate_protocols: Mix.env() != :test, docs: [ @@ -36,4 +37,7 @@ defmodule ExFSM.Mixfile do ] ] end + + def elixirc_paths(:test), do: ["lib", "test/fsm"] + def elixirc_paths(_), do: ["lib"] end diff --git a/test/exfsm_test.exs b/test/exfsm_test.exs index a2bc140..7c9004e 100644 --- a/test/exfsm_test.exs +++ b/test/exfsm_test.exs @@ -2,4 +2,17 @@ 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{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, nil}) + assert state.state == :off + {:next_state, state} = ExFSM.Machine.event(state, {:on, nil}) + assert state.state == :broken + end + end end diff --git a/test/fsm/light.ex b/test/fsm/light.ex new file mode 100644 index 0000000..f5e91b4 --- /dev/null +++ b/test/fsm/light.ex @@ -0,0 +1,32 @@ +defmodule ExFSM.Test.FSM.Light.State do + @type t :: %__MODULE__{ + usage: non_neg_integer(), + state: atom() + } + + @enforced_keys [: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 + + deftrans on({:off, _}, state) do + {:next_state, :off, state} + end + + deftrans off({:on, _}, state) when state.usage > 0 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 From 248416f751340b463eceac50e10a81b52df0c262 Mon Sep 17 00:00:00 2001 From: Krapaince Date: Mon, 27 Jul 2026 11:23:21 +0200 Subject: [PATCH 04/15] test: add transition error handling tests --- test/exfsm_test.exs | 12 ++++++++++++ test/fsm/light.ex | 4 ++++ 2 files changed, 16 insertions(+) diff --git a/test/exfsm_test.exs b/test/exfsm_test.exs index 7c9004e..e236e8f 100644 --- a/test/exfsm_test.exs +++ b/test/exfsm_test.exs @@ -14,5 +14,17 @@ defmodule ExFSMTest do {: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{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{usage: 0, state: :on} + + assert ExFSM.Machine.event(state, {:on, nil}) == {:error, :already_on} + end end end diff --git a/test/fsm/light.ex b/test/fsm/light.ex index f5e91b4..6a51c9c 100644 --- a/test/fsm/light.ex +++ b/test/fsm/light.ex @@ -21,6 +21,10 @@ defmodule ExFSM.Test.FSM.Light do {:next_state, :off, state} end + deftrans on({:on, _}, _) do + {:error, :already_on} + end + deftrans off({:on, _}, state) when state.usage > 0 do {:next_state, :broken, state} end From c2fc0ce6f8703b6487a53e45ba317e8029b457b5 Mon Sep 17 00:00:00 2001 From: Krapaince Date: Mon, 27 Jul 2026 11:28:08 +0200 Subject: [PATCH 05/15] ci: replace travis CI by github workflow --- .github/workflows/ci.yml | 58 ++++++++++++++++++++++++++++++++++++++++ .travis.yml | 24 ----------------- README.md | 2 +- 3 files changed, 59 insertions(+), 25 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .travis.yml 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/README.md b/README.md index 6085f8e..724d5a9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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 (not related at all with `:gen_fsm`, no state/process management). From bf2789c31bea20eab9cbb93c1ff54e3284e5c7b8 Mon Sep 17 00:00:00 2001 From: Krapaince Date: Mon, 27 Jul 2026 11:43:10 +0200 Subject: [PATCH 06/15] refactor!: replace @doc by @{transition,bypass}_doc attribute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @doc is an Elixir reserved attribute which emits a warning when defined multiple time for a function, Like so: warning: redefining @doc attribute previously set at line 20. Please remove the duplicate docs. If instead you want to override a previously defined @doc, attach the @doc attribute to a function head (the function signature not followed by any do-block). For example: @doc """ new docs """ def on(...) │ 25 │ @doc "..." │ ~~~~~~~~~~~ │ └─ test/fsm/light.ex:25: ExFSM.Test.FSM.Light.on/2 Using @transition_doc and @bypass_doc fixes this issue. It also fixes the value returned by `docs/0` as the @doc attribute comes as a tuple `{line, doc}` instead of the wanted value `doc`. --- CHANGELOG.md | 7 +++++++ lib/exfsm.ex | 34 ++++++++++++++++++++-------------- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03667f4..9419cc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,3 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. + +### 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/lib/exfsm.ex b/lib/exfsm.ex index 581e9e0..2e8f07e 100644 --- a/lib/exfsm.ex +++ b/lib/exfsm.ex @@ -18,47 +18,51 @@ defmodule ExFSM 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 @@ -111,9 +115,10 @@ defmodule ExFSM do {unquote(state), unquote(transition)}, {__MODULE__, @to || unquote(Enum.uniq(find_nextstates(body_block[:do])))} ) - doc = Module.get_attribute(__MODULE__, :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 @@ -140,9 +145,10 @@ defmodule ExFSM do 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 end @@ -168,9 +174,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]) From 3b14d20dd1d97bcb846ec434081d450402fa4d1a Mon Sep 17 00:00:00 2001 From: Krapaince Date: Mon, 27 Jul 2026 13:15:21 +0200 Subject: [PATCH 07/15] chore: bump exdoc from 0.38.4 to 0.40.3 --- mix.lock | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) 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"}, } From f6d5e87383f780ad4ba901ec96db8da51b4fe1b3 Mon Sep 17 00:00:00 2001 From: Krapaince Date: Mon, 27 Jul 2026 19:15:49 +0200 Subject: [PATCH 08/15] fix: detect out states from transition with multiple function heads correctly --- CHANGELOG.md | 6 ++++++ lib/exfsm.ex | 12 ++++++++++-- test/exfsm_test.exs | 20 ++++++++++++++++---- test/fsm/light.ex | 15 ++++++++++++--- 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9419cc4..df8527a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 importing the dependency in your `.formatter.exs` file. - Macros `deftrans` and `defbypass` now supports `when` clause. + +### 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 diff --git a/lib/exfsm.ex b/lib/exfsm.ex index 2e8f07e..b698c15 100644 --- a/lib/exfsm.ex +++ b/lib/exfsm.ex @@ -110,10 +110,18 @@ defmodule ExFSM do 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(transition)}, - {__MODULE__, @to || unquote(Enum.uniq(find_nextstates(body_block[:do])))} + {__MODULE__, output_states}, + fn {module, prev_output_state} -> + {module, Enum.uniq(prev_output_state ++ output_states)} + end ) doc = Module.get_attribute(__MODULE__, :transition_doc) @docs Map.put(@docs, {:transition_doc, unquote(state), unquote(transition)}, doc) diff --git a/test/exfsm_test.exs b/test/exfsm_test.exs index e236e8f..04cbc5e 100644 --- a/test/exfsm_test.exs +++ b/test/exfsm_test.exs @@ -5,26 +5,38 @@ defmodule ExFSMTest do describe "ExFSM" do test "should perform transitions" do - state = %ExFSM.Test.FSM.Light.State{usage: 0, state: :off} + 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, nil}) + {: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{usage: 0, state: :off} + 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{usage: 0, state: :on} + 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 index 6a51c9c..ac1702c 100644 --- a/test/fsm/light.ex +++ b/test/fsm/light.ex @@ -1,10 +1,11 @@ defmodule ExFSM.Test.FSM.Light.State do @type t :: %__MODULE__{ + max_usage: non_neg_integer(), usage: non_neg_integer(), state: atom() } - @enforced_keys [:state, :usage] + @enforced_keys [:max_usage, :state, :usage] defstruct @enforced_keys end @@ -17,15 +18,23 @@ end defmodule ExFSM.Test.FSM.Light do use ExFSM - deftrans on({:off, _}, state) do + 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 > 0 do + deftrans off({:on, _}, state) when state.usage >= state.max_usage do {:next_state, :broken, state} end From ba5a879db310514974fbbca6d82c3b37f6b997be Mon Sep 17 00:00:00 2001 From: Krapaince Date: Tue, 28 Jul 2026 10:25:07 +0200 Subject: [PATCH 09/15] docs: improve existing doc, add types and enrich typespecs. --- lib/exfsm.ex | 102 +++++++++++++++++++++++++++++++++------------------ 1 file changed, 67 insertions(+), 35 deletions(-) diff --git a/lib/exfsm.ex b/lib/exfsm.ex index b698c15..516a866 100644 --- a/lib/exfsm.ex +++ b/lib/exfsm.ex @@ -1,22 +1,19 @@ 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. - For instance : + For instance: iex> defmodule Elixir.Door do ...> use ExFSM - ...> @moduledoc false ...> ...> @transition_doc "Close to open" ...> @to [:opened] @@ -65,6 +62,19 @@ defmodule ExFSM 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 quote do import ExFSM @@ -85,16 +95,27 @@ defmodule ExFSM do 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(signature, body_block) do {state, transition} = case signature do @@ -163,16 +184,14 @@ 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. @@ -213,16 +232,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(%{}) @@ -233,9 +258,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 @@ -243,7 +268,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)) @@ -267,13 +293,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 -> @@ -311,7 +343,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) @@ -322,7 +354,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 From f6d12cdbb468800e9ca9464bb4e5d595c86a3761 Mon Sep 17 00:00:00 2001 From: Krapaince Date: Tue, 28 Jul 2026 10:41:13 +0200 Subject: [PATCH 10/15] docs: improve README.md --- README.md | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 724d5a9..269a6c8 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,41 @@ [![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) From 2f792fc5650357f0fd17d595be5d55eb6a7a40b1 Mon Sep 17 00:00:00 2001 From: Krapaince Date: Tue, 28 Jul 2026 15:14:41 +0200 Subject: [PATCH 11/15] feat: add sigil_FSM and its formatter plugin --- .formatter.exs | 1 + CHANGELOG.md | 2 +- lib/dummy_fsm.ex | 5 + lib/exfsm.ex | 133 ++++++++++++++++++++++++++- lib/exfsm/formatter_plugin.ex | 55 +++++++++++ test/exfsm/formatter_plugin_test.exs | 27 ++++++ test/fsm/light.ex | 6 ++ 7 files changed, 226 insertions(+), 3 deletions(-) create mode 100644 lib/exfsm/formatter_plugin.ex create mode 100644 test/exfsm/formatter_plugin_test.exs diff --git a/.formatter.exs b/.formatter.exs index af85b2f..f6298b4 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,6 +1,7 @@ export_locals_without_parens = [defbypass: 2, deftrans: 2] [ + 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/CHANGELOG.md b/CHANGELOG.md index df8527a..02ad56b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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`. ### Fixed diff --git a/lib/dummy_fsm.ex b/lib/dummy_fsm.ex index 0d59c24..8e9a209 100644 --- a/lib/dummy_fsm.ex +++ b/lib/dummy_fsm.ex @@ -31,6 +31,11 @@ defmodule ExFSM.Dummy.FSM do 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 516a866..84fbd3b 100644 --- a/lib/exfsm.ex +++ b/lib/exfsm.ex @@ -86,9 +86,11 @@ defmodule ExFSM do 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 @@ -180,6 +182,133 @@ defmodule ExFSM 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 + + @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 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/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/fsm/light.ex b/test/fsm/light.ex index ac1702c..9888902 100644 --- a/test/fsm/light.ex +++ b/test/fsm/light.ex @@ -18,6 +18,12 @@ 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 From 80d49dfaa5d745035565b0f3d421dde342478eb8 Mon Sep 17 00:00:00 2001 From: Krapaince Date: Tue, 28 Jul 2026 16:51:03 +0200 Subject: [PATCH 12/15] feat: add options with_{action_name_macros,action_names_guard} --- CHANGELOG.md | 2 ++ lib/exfsm.ex | 49 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02ad56b..6d55399 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 diff --git a/lib/exfsm.ex b/lib/exfsm.ex index 84fbd3b..1831792 100644 --- a/lib/exfsm.ex +++ b/lib/exfsm.ex @@ -10,6 +10,16 @@ defmodule ExFSM do `{:next_state,xxx,xxx}` is defined outside the `deftrans/2` function, you 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. + + * `: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 @@ -75,7 +85,10 @@ defmodule ExFSM do {exfsm_module :: handler(), [dest_statename :: state_name()]} } - defmacro __using__(_opts) do + 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 %{} @@ -83,6 +96,12 @@ 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 @@ -187,6 +206,34 @@ defmodule ExFSM do 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. From 908ef56c4cafab966067d22f63414943366091b9 Mon Sep 17 00:00:00 2001 From: Krapaince Date: Mon, 27 Jul 2026 13:16:36 +0200 Subject: [PATCH 13/15] chore: bump exfsm version to 1.0.0 --- CHANGELOG.md | 2 +- mix.exs | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d55399..3b564b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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). -## Unreleased +## 1.0.0 - 2026/07/27 ### Added diff --git a/mix.exs b/mix.exs index 3f46442..11a9c50 100644 --- a/mix.exs +++ b/mix.exs @@ -1,10 +1,12 @@ 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" @@ -17,7 +19,7 @@ defmodule ExFSM.Mixfile do docs: [ main: "ExFSM", extras: ["CHANGELOG.md"], - source_url: "https://github.com/kbrw/exfsm/tree/v0.1.6", + source_url: "https://github.com/kbrw/exfsm/tree/v#{@version}", source_ref: "master" ], description: """ From f4bfa52947f86d28bcb341827b949e44f5bef284 Mon Sep 17 00:00:00 2001 From: Krapaince Date: Wed, 12 Aug 2026 15:24:15 +0200 Subject: [PATCH 14/15] fixup! docs: improve README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 269a6c8..f1b3979 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ FSM](https://en.wikipedia.org/wiki/Mealy_machine) as function (not related at all with `:gen_fsm`, no state/process management). ```elixir -defmodule Ligh do +defmodule Light do use ExFSM deftrans on({:off, _}, state), do: {:next_state, :off, state} @@ -22,7 +22,7 @@ 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] + def handlers(_state), do: [Light] end {:next_state, %_{state: :off} = state} = ExFSM.Machine.event({:on, nil}, %Light{state: :off}) From 845834863560281d96bc52ad4c6fe437ebddde95 Mon Sep 17 00:00:00 2001 From: Krapaince Date: Wed, 12 Aug 2026 15:59:40 +0200 Subject: [PATCH 15/15] fixup! feat: add sigil_FSM and its formatter plugin --- lib/exfsm.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/exfsm.ex b/lib/exfsm.ex index 1831792..9b85e12 100644 --- a/lib/exfsm.ex +++ b/lib/exfsm.ex @@ -238,7 +238,7 @@ defmodule ExFSM do 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: + transition. This sigil's purpose is only for documentation: ~FSM""" on -- off -> [off, broken]