defmodule Replika do @moduledoc """ # Replika Replika is a pure functional finite state machine library for Elixir. Unlike process-based state machines, Replika implements state machines as immutable data structures that transform as they transition between states. This provides significant performance benefits while simplifying state management. ## Key Benefits * **High Performance** - State transitions are simple struct updates (O(1)) * **No Process Overhead** - Works as a lightweight data structure * **Immutable & Pure** - Predictable state transitions without side effects * **Embeddable** - Can be stored in ETS, databases, or embedded in other processes * **Pattern Matching** - Leverages Elixir's pattern matching for state logic ## State Machine Visualization The following diagram illustrates a simple state machine with four states: ``` +---------------+ | v +----------------+ +----------------+ +-------------------+ | :no_id_card |--->| :has_id_card |--->| :at_elevator | +----------------+ +----------------+ +-------------------+ | v +--------------------+ | :elevator_accessed | +--------------------+ ``` ## Basic Example ```elixir defmodule Unit.AccessState do use Replika, initial_state: :no_id_card, initial_data: %{inventory: []} defstate no_id_card do defevent pickup_card do next_state(:has_id_card, %{inventory: [:id_card]}) end end defstate has_id_card do defevent approach_elevator do next_state(:at_elevator, %{inventory: [:id_card]}) end end defstate at_elevator do defevent use_card, data: %{inventory: inventory} do if :id_card in inventory do next_state(:elevator_accessed, %{inventory: inventory}) else # Remain in the same state if missing card next_state(:at_elevator, %{inventory: inventory}) end end defevent leave_elevator do next_state(:has_id_card, %{inventory: [:id_card]}) end end defstate elevator_accessed do defevent select_floor(floor) do respond({:travelling_to, floor}, :elevator_accessed, %{inventory: [:id_card]}) end end end # Usage: elster = Unit.AccessState.new() # Progress through the game elster = elster |> Unit.AccessState.pickup_card() |> Unit.AccessState.approach_elevator() |> Unit.AccessState.use_card() # Unit can now use the elevator Unit.AccessState.state(elster) # :elevator_accessed # Select a floor {:travelling_to, floor, elster} = Unit.AccessState.select_floor(elster, "b6") ``` ## Working with Data FSMs often need to carry data alongside their state: ```elixir defmodule InventorySystem do use Replika, initial_state: :empty, initial_data: %{items: []} defstate empty do defevent add_item(item), data: %{items: items} do new_items = [item | items] if length(new_items) > 0 do next_state(:has_items, %{items: new_items}) else next_state(:empty, %{items: new_items}) end end end defstate has_items do defevent add_item(item), data: %{items: items} do next_state(:has_items, %{items: [item | items]}) end defevent remove_item(item), data: %{items: items} do new_items = List.delete(items, item) if new_items == [] do next_state(:empty, %{items: []}) else next_state(:has_items, %{items: new_items}) end end defevent list_items, data: %{items: items} do respond(items) end end end ``` ## Error Handling Replika provides clear error messages for invalid transitions: ```elixir # Trying an invalid transition inventory = InventorySystem.new() |> InventorySystem.add_item("Keycard") # This would cause an error because 'list_items' is only defined in :has_items state InventorySystem.list_items(inventory) # You'll see a clear error: # ** (Replika.Error.InvalidTransitionError) Invalid transition: cannot execute # event 'list_items' with args [] in state ':empty' ``` ## Pattern Matching and Guards You can leverage Elixir's pattern matching and guards for sophisticated state logic: ```elixir defstate at_elevator do # Different handling based on item in inventory defevent use_card, data: %{inventory: inventory} when :id_card in inventory do next_state(:elevator_accessed, %{inventory: inventory}) end # Handle case where ID card is missing defevent use_card, data: %{inventory: inventory} do respond({:error, :missing_id_card}, :at_elevator, %{inventory: inventory}) end end ``` ## Performance Considerations Replika is designed for high performance with minimal overhead: * Transitions are O(1) operations (simple struct updates) * Pattern matching is optimized by the BEAM VM * No message passing overhead between processes * Low memory footprint at scale """ # Inline hot functions @compile {:inline, [ next_state: 1, next_state: 2, respond: 1, respond: 2, respond: 3 ]} @doc """ When used, defines a new Replika state machine. ## Options * `:initial_state` - The initial state of the FSM (required) * `:initial_data` - The initial data of the FSM (optional, defaults to `nil`) ## Examples defmodule Unit.AccessState do use Replika, initial_state: :no_id_card, initial_data: %{inventory: []} end """ defmacro __using__(opts) do quote do import Replika, only: [ next_state: 1, next_state: 2, respond: 1, respond: 2, respond: 3, defstate: 2, defevent: 1, defevent: 2, defevent: 3, defeventp: 1, defeventp: 2, defeventp: 3 ] @initial_state unquote(opts[:initial_state]) || raise(ArgumentError, "initial_state is required") defstruct state: @initial_state, data: unquote(opts[:initial_data]) @declaring_state nil @declared_events MapSet.new() @doc """ Creates a new FSM instance. ## Parameters - `params`: Optional keyword list of parameters to override defaults ## Examples # Create with default state and data Unit.AccessState.new() # Override the initial state Unit.AccessState.new(state: :has_id_card) # Override both state and data Unit.AccessState.new(state: :has_id_card, data: %{inventory: [:id_card]}) """ @spec new(keyword()) :: %__MODULE__{} def new(params \\ []), do: struct!(__MODULE__, params) @doc """ Returns the current state of the FSM. ## Examples fsm = Unit.AccessState.new() Unit.AccessState.state(fsm) # Returns :no_id_card """ @spec state(%__MODULE__{}) :: atom() def state(%__MODULE__{state: state}), do: state @doc """ Returns the current data of the FSM. ## Examples fsm = Unit.AccessState.new() Unit.AccessState.data(fsm) # Returns %{inventory: []} """ @spec data(%__MODULE__{}) :: any() def data(%__MODULE__{data: data}), do: data @dialyzer {:no_match, change_state: 2} defp change_state(%__MODULE__{} = fsm, {:action_responses, responses}), do: parse_action_responses(fsm, responses) defp change_state(%__MODULE__{} = fsm, _), do: fsm defp parse_action_responses(fsm, responses) do # Extract the responses by type for direct application {next_state, new_data, response} = extract_responses(responses) # Apply the transformations directly fsm = if next_state, do: %__MODULE__{fsm | state: next_state}, else: fsm fsm = if new_data, do: %__MODULE__{fsm | data: new_data}, else: fsm # Return with response if present if response, do: {response, fsm}, else: fsm end defp extract_responses(responses) do Enum.reduce(responses, {nil, nil, nil}, fn {:next_state, state}, {_, data, resp} -> {state, data, resp} {:new_data, data}, {state, _, resp} -> {state, data, resp} {:respond, resp}, {state, data, _} -> {state, data, resp} end) end end end @doc """ Transitions to a new state without changing data. This function is used within event handlers to change the state machine's state while preserving its existing data. ## Parameters - `state`: The target state to transition to (atom) ## Returns An action response tuple that will be processed by the state machine. ## Examples defevent pickup_card do next_state(:has_id_card) # Transition to :has_id_card state end """ @spec next_state(atom()) :: {:action_responses, [{:next_state, atom()}]} def next_state(state), do: {:action_responses, [next_state: state]} @doc """ Transitions to a new state and updates data. This function is used within event handlers to simultaneously change the state machine's state and update its associated data. ## Parameters - `state`: The target state to transition to (atom) - `data`: The new data value to store ## Examples defevent pickup_card do next_state(:has_id_card, %{inventory: [:id_card]}) end """ @spec next_state(atom(), any()) :: {:action_responses, [{:next_state, atom()} | {:new_data, any()}]} def next_state(state, data), do: {:action_responses, [next_state: state, new_data: data]} @doc """ Returns a response without changing state or data. This function is used within event handlers to return a value to the caller without modifying the state machine's state or data. ## Parameters - `response`: The value to return from the event handler ## Examples defevent list_items, data: %{items: items} do respond(items) # Return items to caller, no state/data change end """ @spec respond(any()) :: {:action_responses, [{:respond, any()}]} def respond(response), do: {:action_responses, [respond: response]} @doc """ Returns a response and transitions to a new state. ## Parameters - `response`: The value to return from the event handler - `state`: The state to transition to ## Examples defevent examine_card do respond({:card_info, "SECTOR B ACCESS"}, :has_id_card) end """ @spec respond(any(), atom()) :: {:action_responses, [{:next_state, atom()} | {:respond, any()}]} def respond(response, state), do: {:action_responses, [next_state: state, respond: response]} @doc """ Returns a response, transitions to a new state, and updates data. ## Parameters - `response`: The value to return from the event handler - `state`: The state to transition to - `data`: The new data value ## Examples defevent select_floor(floor) do respond({:travelling_to, floor}, :elevator_accessed, %{inventory: [:id_card]}) end """ @spec respond(any(), atom(), any()) :: {:action_responses, [{:next_state, atom()} | {:new_data, any()} | {:respond, any()}]} def respond(response, state, data), do: {:action_responses, [next_state: state, new_data: data, respond: response]} @doc """ Defines a state in the FSM. ## Parameters - `state`: The name of the state - `state_def`: The state definition block ## Examples defstate at_elevator do defevent use_card, data: %{inventory: inventory} do if :id_card in inventory do next_state(:elevator_accessed, %{inventory: inventory}) else next_state(:at_elevator, %{inventory: inventory}) end end end """ defmacro defstate(state, state_def) do quote do state_name = case unquote(Macro.escape(state, unquote: true)) do name when is_atom(name) -> name {name, _, _} -> name end @declaring_state state_name unquote(state_def) @declaring_state nil end end @doc """ Declares an event in the FSM without implementation. ## Parameters - `event`: The name of the event (and optionally its arity) ## Examples # Declare a zero-arity event defevent pickup_card # Declare a two-arity event defevent use_item/2 """ defmacro defevent(event) when is_atom(event) or is_tuple(event) do decl_event(event, false) end @doc """ Defines an event in the FSM with options but no implementation block. ## Parameters - `event`: The name of the event - `opts`: Options for the event (must include :do option with the implementation) ## Examples # Event with implementation in the options defevent pickup_card, do: next_state(:has_id_card, %{inventory: [:id_card]}) """ defmacro defevent(event, opts) when is_list(opts) do if Keyword.has_key?(opts, :do) do do_defevent(event, opts, opts[:do]) else raise ArgumentError, "defevent/2 requires a :do option when given a keyword list" end end @doc """ Defines an event in the FSM with options and implementation block. ## Parameters - `event`: The name of the event - `opts`: Options for the event - `event_def`: The event implementation block ## Examples # Event with options and block defevent use_card, data: %{inventory: inventory} do if :id_card in inventory do next_state(:elevator_accessed, %{inventory: inventory}) else next_state(:at_elevator, %{inventory: inventory}) end end """ defmacro defevent(event, opts, do: event_def) do do_defevent(event, opts, event_def) end @doc """ Declares a private event in the FSM without implementation. Works the same as `defevent/1` but generates a private function instead of a public one. ## Examples # Declare a private zero-arity event defeventp internal_transition """ defmacro defeventp(event) when is_atom(event) or is_tuple(event) do decl_event(event, true) end @doc """ Defines a private event in the FSM with options but no implementation block. ## Parameters - `event`: The name of the event - `opts`: Options for the event (must include :do option with the implementation) ## Examples # Private event with implementation in the options defeventp internal_scan, do: next_state(:scanning) """ defmacro defeventp(event, opts) when is_list(opts) do if Keyword.has_key?(opts, :do) do do_defevent(event, [{:private, true} | opts], opts[:do]) else raise ArgumentError, "defeventp/2 requires a :do option when given a keyword list" end end @doc """ Defines a private event in the FSM with options and implementation block. ## Parameters - `event`: The name of the event - `opts`: Options for the event - `event_def`: The event implementation block ## Examples # Private event with options and block defeventp validate_card, data: %{inventory: inventory} do :id_card in inventory end """ defmacro defeventp(event, opts, do: event_def) do do_defevent(event, [{:private, true} | opts], event_def) end # Implementation details defp do_defevent(event_decl, opts, event_def) do quote do unquote(extract_args(event_decl, opts, event_def)) unquote(define_interface()) unquote(implement_transition()) end end defp extract_args(event_decl, opts, event_def) do quote do {event_name, args} = case unquote(Macro.escape(event_decl, unquote: true)) do :_ -> {:_, []} name when is_atom(name) -> {name, []} {name, _, args} -> {name, args || []} end private = unquote(opts[:private]) state_arg = unquote(Macro.escape(opts[:state] || quote(do: _), unquote: true)) data_arg = unquote(Macro.escape(opts[:data] || quote(do: _), unquote: true)) event_arg = unquote(Macro.escape(opts[:event] || quote(do: _), unquote: true)) args_arg = unquote(Macro.escape(opts[:args] || quote(do: _), unquote: true)) event_def = unquote(Macro.escape(event_def, unquote: true)) guard = unquote(Macro.escape(opts[:when])) end end defp define_interface do quote bind_quoted: [] do unless event_name == :_ or MapSet.member?(@declared_events, {event_name, length(args)}) do interface_args = if args == [] do [] else for idx <- 0..(length(args) - 1), do: {:"arg#{idx}", [], nil} end body = quote do try do transition(fsm, unquote(event_name), [unquote_splicing(interface_args)]) rescue FunctionClauseError -> reraise Replika.Error.InvalidTransitionError, [ state: state(fsm), event: unquote(event_name), args: [unquote_splicing(interface_args)] ], __STACKTRACE__ end end interface_args = [quote(do: fsm) | interface_args] if private do defp unquote(event_name)(unquote_splicing(interface_args)), do: unquote(body) else def unquote(event_name)(unquote_splicing(interface_args)), do: unquote(body) end @declared_events MapSet.put(@declared_events, {event_name, length(args)}) end end end defp implement_transition do quote bind_quoted: [] do transition_args = [ if @declaring_state do quote do %__MODULE__{ state: unquote(@declaring_state) = unquote(state_arg), data: unquote(data_arg) } = fsm end else quote do %__MODULE__{state: unquote(state_arg), data: unquote(data_arg)} = fsm end end, quote do unquote(if event_name == :_, do: quote(do: _any_event), else: event_name) = unquote(event_arg) end, quote do unquote(if event_name == :_, do: quote(do: _any_args), else: args) = unquote(args_arg) end ] body = quote(do: change_state(fsm, unquote(event_def))) if guard do def transition(unquote_splicing(transition_args)) when unquote(guard), do: unquote(body) else def transition(unquote_splicing(transition_args)), do: unquote(body) end end end defp decl_event(event, private) do quote do {event_name, arity} = case unquote(Macro.escape(event, unquote: nil)) do event_name when is_atom(event_name) -> {event_name, 0} {:/, _, [{event_name, _, _}, arity]} -> {event_name, arity} {event_name, _, _} -> {event_name, 0} end args = case arity do 0 -> [] n -> Enum.to_list(1..n) end private = unquote(private) unquote(define_interface()) end end end