Packages

Type-safe finite state machine library for Gleam

Current section

Files

Jump to
scamper src scamper@transition.erl
Raw

src/scamper@transition.erl

-module(scamper@transition).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/scamper/transition.gleam").
-export([execute/5, can_execute/4, available_events/2]).
-export_type([transition_result/3]).
-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.
?MODULEDOC(
" Transition engine for scamper FSMs.\n"
"\n"
" Handles guard evaluation, callback execution, invariant checking,\n"
" and rollback on failure. This is the core algorithmic module.\n"
).
-type transition_result(HBV, HBW, HBX) :: {transition_result,
HBV,
HBX,
list(scamper@history:transition_record(HBV, HBW, HBX)),
integer()}.
-file("src/scamper/transition.gleam", 125).
-spec find_matching_rule_loop(
list(scamper@config:transition_rule(HEM, HEN, HEO)),
HEN,
HEO,
gleam@option:option(scamper@config:transition_rule(HEM, HEN, HEO))
) -> gleam@option:option(scamper@config:transition_rule(HEM, HEN, HEO)).
find_matching_rule_loop(Rules, Context, Event, Fallback) ->
case Rules of
[] ->
Fallback;
[Rule | Rest] ->
case erlang:element(5, Rule) of
none ->
case Fallback of
none ->
find_matching_rule_loop(
Rest,
Context,
Event,
{some, Rule}
);
{some, _} ->
find_matching_rule_loop(
Rest,
Context,
Event,
Fallback
)
end;
{some, Guard_fn} ->
case Guard_fn(Context, Event) of
true ->
{some, Rule};
false ->
find_matching_rule_loop(
Rest,
Context,
Event,
Fallback
)
end
end
end.
-file("src/scamper/transition.gleam", 117).
?DOC(
" Find the first matching rule by evaluating guards top-to-bottom.\n"
" Rules with Some(guard) are tested; rules with None (fallback) are collected.\n"
" First passing guarded rule wins. If none pass, use fallback if available.\n"
).
-spec find_matching_rule(
list(scamper@config:transition_rule(HEB, HEC, HED)),
HEC,
HED
) -> gleam@option:option(scamper@config:transition_rule(HEB, HEC, HED)).
find_matching_rule(Rules, Context, Event) ->
find_matching_rule_loop(Rules, Context, Event, none).
-file("src/scamper/transition.gleam", 241).
?DOC(" Run global state callbacks, then state-specific callback.\n").
-spec run_state_callbacks(
HGC,
HGD,
list(fun((HGC, HGD) -> {ok, HGD} | {error, binary()})),
gleam@option:option(fun((HGC, HGD) -> {ok, HGD} | {error, binary()})),
scamper@error:callback_stage()
) -> {ok, HGD} | {error, scamper@error:transition_error(HGC, any())}.
run_state_callbacks(
Target_state,
Context,
Global_callbacks,
State_callback,
Stage
) ->
gleam@result:'try'(
gleam@list:try_fold(
Global_callbacks,
Context,
fun(Ctx, Cb) -> _pipe = Cb(Target_state, Ctx),
gleam@result:map_error(
_pipe,
fun(Reason) -> {callback_failed, Stage, Reason} end
) end
),
fun(Ctx@1) -> case State_callback of
none ->
{ok, Ctx@1};
{some, Cb@1} ->
_pipe@1 = Cb@1(Target_state, Ctx@1),
gleam@result:map_error(
_pipe@1,
fun(Reason@1) -> {callback_failed, Stage, Reason@1} end
)
end end
).
-file("src/scamper/transition.gleam", 270).
?DOC(" Run global transition callbacks, then state-specific transition callback.\n").
-spec run_transition_callbacks(
HGP,
HGQ,
HGP,
HGR,
list(fun((HGP, HGQ, HGP, HGR) -> {ok, HGR} | {error, binary()})),
gleam@option:option(fun((HGP, HGQ, HGP, HGR) -> {ok, HGR} |
{error, binary()})),
scamper@error:callback_stage()
) -> {ok, HGR} | {error, scamper@error:transition_error(HGP, HGQ)}.
run_transition_callbacks(
From,
Event,
To,
Context,
Global_callbacks,
State_callback,
Stage
) ->
gleam@result:'try'(
gleam@list:try_fold(
Global_callbacks,
Context,
fun(Ctx, Cb) -> _pipe = Cb(From, Event, To, Ctx),
gleam@result:map_error(
_pipe,
fun(Reason) -> {callback_failed, Stage, Reason} end
) end
),
fun(Ctx@1) -> case State_callback of
none ->
{ok, Ctx@1};
{some, Cb@1} ->
_pipe@1 = Cb@1(From, Event, To, Ctx@1),
gleam@result:map_error(
_pipe@1,
fun(Reason@1) -> {callback_failed, Stage, Reason@1} end
)
end end
).
-file("src/scamper/transition.gleam", 301).
?DOC(" Find a state-specific callback for a given state.\n").
-spec find_state_callback(
list({HHE, fun((HHE, HHF) -> {ok, HHF} | {error, binary()})}),
HHE
) -> gleam@option:option(fun((HHE, HHF) -> {ok, HHF} | {error, binary()})).
find_state_callback(Callbacks, Target) ->
case gleam@list:find(
Callbacks,
fun(Entry) -> erlang:element(1, Entry) =:= Target end
) of
{ok, {_, Cb}} ->
{some, Cb};
{error, _} ->
none
end.
-file("src/scamper/transition.gleam", 312).
?DOC(" Find a state-specific transition callback for a given from-state.\n").
-spec find_transition_callback(
list({HHM, fun((HHM, HHO, HHM, HHN) -> {ok, HHN} | {error, binary()})}),
HHM
) -> gleam@option:option(fun((HHM, HHO, HHM, HHN) -> {ok, HHN} |
{error, binary()})).
find_transition_callback(Callbacks, Target) ->
case gleam@list:find(
Callbacks,
fun(Entry) -> erlang:element(1, Entry) =:= Target end
) of
{ok, {_, Cb}} ->
{some, Cb};
{error, _} ->
none
end.
-file("src/scamper/transition.gleam", 203).
?DOC(
" Run all callbacks in the guaranteed order:\n"
" 1. Global on_exit → 2. State-specific on_exit(from)\n"
" 3. Global on_transition → 4. State-specific on_transition(from)\n"
" 5. Global on_enter → 6. State-specific on_enter(to)\n"
).
-spec run_all_callbacks(
HFS,
HFT,
HFS,
HFU,
scamper@config:config(HFS, HFU, HFT)
) -> {ok, HFU} | {error, scamper@error:transition_error(HFS, HFT)}.
run_all_callbacks(From, Event, To, Context, Config) ->
gleam@result:'try'(
run_state_callbacks(
From,
Context,
scamper@config:get_global_on_exit(Config),
find_state_callback(scamper@config:get_on_exit(Config), From),
on_exit
),
fun(Ctx) ->
gleam@result:'try'(
run_transition_callbacks(
From,
Event,
To,
Ctx,
scamper@config:get_global_on_transition(Config),
find_transition_callback(
scamper@config:get_on_transition_state(Config),
From
),
on_transition
),
fun(Ctx@1) ->
run_state_callbacks(
To,
Ctx@1,
scamper@config:get_global_on_enter(Config),
find_state_callback(
scamper@config:get_on_enter(Config),
To
),
on_enter
)
end
)
end
).
-file("src/scamper/transition.gleam", 323).
?DOC(" Run all context invariants. Returns Ok or InvariantViolation.\n").
-spec run_invariants(HHX, scamper@config:config(HHY, HHX, HHZ)) -> {ok, nil} |
{error, scamper@error:transition_error(HHY, HHZ)}.
run_invariants(Context, Config) ->
Invariants = scamper@config:get_invariants(Config),
gleam@list:try_fold(Invariants, nil, fun(_, Inv) -> _pipe = Inv(Context),
gleam@result:map_error(
_pipe,
fun(Reason) -> {invariant_violation, Reason} end
) end).
-file("src/scamper/transition.gleam", 151).
?DOC(" Execute the transition with callbacks and invariants.\n").
-spec execute_transition(
HFB,
HFC,
HFD,
HFB,
scamper@config:config(HFB, HFC, HFD),
list(scamper@history:transition_record(HFB, HFD, HFC))
) -> {ok, transition_result(HFB, HFD, HFC)} |
{error, scamper@error:transition_error(HFB, HFD)}.
execute_transition(From, Context, Event, To, Config, Current_history) ->
gleam@result:'try'(
run_all_callbacks(From, Event, To, Context, Config),
fun(Ctx_after_callbacks) ->
gleam@result:'try'(
run_invariants(Ctx_after_callbacks, Config),
fun(_) ->
Timestamp = scamper@config:get_timestamp(Config),
Snapshot = case scamper@config:get_history_snapshots(Config) of
true ->
{some, Ctx_after_callbacks};
false ->
none
end,
Record = scamper@history:new_record(
From,
Event,
To,
Timestamp,
Snapshot
),
New_history = scamper@history:append(
Current_history,
Record,
scamper@config:get_history_limit(Config)
),
{ok,
{transition_result,
To,
Ctx_after_callbacks,
New_history,
Timestamp}}
end
)
end
).
-file("src/scamper/transition.gleam", 90).
-spec resolve_guards(
HDG,
HDH,
HDI,
list(scamper@config:transition_rule(HDG, HDH, HDI)),
scamper@config:config(HDG, HDH, HDI),
list(scamper@history:transition_record(HDG, HDI, HDH))
) -> {ok, transition_result(HDG, HDI, HDH)} |
{error, scamper@error:transition_error(HDG, HDI)}.
resolve_guards(From, Context, Event, Rules, Config, Current_history) ->
case find_matching_rule(Rules, Context, Event) of
{some, Rule} ->
execute_transition(
From,
Context,
Event,
erlang:element(4, Rule),
Config,
Current_history
);
none ->
{error,
{guard_rejected, From, Event, <<"All guards rejected"/utf8>>}}
end.
-file("src/scamper/transition.gleam", 57).
-spec execute_non_final(
HCP,
HCQ,
HCR,
scamper@config:config(HCP, HCQ, HCR),
list(scamper@history:transition_record(HCP, HCR, HCQ))
) -> {ok, transition_result(HCP, HCR, HCQ)} |
{error, scamper@error:transition_error(HCP, HCR)}.
execute_non_final(State, Context, Event, Config, Current_history) ->
Matching_rules = begin
_pipe = scamper@config:get_transitions(Config),
gleam@list:filter(
_pipe,
fun(Rule) ->
(erlang:element(2, Rule) =:= State) andalso (erlang:element(
3,
Rule
)
=:= Event)
end
)
end,
case Matching_rules of
[] ->
case scamper@config:get_event_policy(Config) of
ignore ->
{ok,
{transition_result,
State,
Context,
Current_history,
scamper@config:get_timestamp(Config)}};
reject ->
{error, {invalid_transition, State, Event}}
end;
Rules ->
resolve_guards(
State,
Context,
Event,
Rules,
Config,
Current_history
)
end.
-file("src/scamper/transition.gleam", 39).
?DOC(
" Execute a state transition.\n"
"\n"
" Algorithm:\n"
" 1. Check if in final state → AlreadyFinal\n"
" 2. Find matching rules for (from, event)\n"
" 3. No rules → check event policy (Reject/Ignore)\n"
" 4. Evaluate guards top-to-bottom, first passing wins\n"
" 5. Execute callbacks: on_exit → on_transition → on_enter\n"
" 6. Run invariants on new context\n"
" 7. Record history\n"
).
-spec execute(
HBY,
HBZ,
HCA,
scamper@config:config(HBY, HBZ, HCA),
list(scamper@history:transition_record(HBY, HCA, HBZ))
) -> {ok, transition_result(HBY, HCA, HBZ)} |
{error, scamper@error:transition_error(HBY, HCA)}.
execute(State, Context, Event, Config, Current_history) ->
Final_states = scamper@config:get_final_states(Config),
case gleam@list:contains(Final_states, State) of
true ->
{error, {already_final, State}};
false ->
execute_non_final(State, Context, Event, Config, Current_history)
end.
-file("src/scamper/transition.gleam", 336).
?DOC(
" Check whether a transition is possible without executing it.\n"
" Only checks final state, matching rules, and guards.\n"
).
-spec can_execute(HIH, HII, HIJ, scamper@config:config(HIH, HIJ, HII)) -> boolean().
can_execute(State, Event, Context, Config) ->
Final_states = scamper@config:get_final_states(Config),
case gleam@list:contains(Final_states, State) of
true ->
false;
false ->
Matching_rules = begin
_pipe = scamper@config:get_transitions(Config),
gleam@list:filter(
_pipe,
fun(Rule) ->
(erlang:element(2, Rule) =:= State) andalso (erlang:element(
3,
Rule
)
=:= Event)
end
)
end,
case Matching_rules of
[] ->
case scamper@config:get_event_policy(Config) of
ignore ->
true;
reject ->
false
end;
Rules ->
case find_matching_rule(Rules, Context, Event) of
{some, _} ->
true;
none ->
false
end
end
end.
-file("src/scamper/transition.gleam", 367).
?DOC(
" Get all events that have at least one matching transition rule\n"
" from the current state. Does not evaluate guards.\n"
).
-spec available_events(HIN, scamper@config:config(HIN, any(), HIP)) -> list(HIP).
available_events(State, Config) ->
_pipe = scamper@config:get_transitions(Config),
_pipe@1 = gleam@list:filter(
_pipe,
fun(Rule) -> erlang:element(2, Rule) =:= State end
),
_pipe@2 = gleam@list:map(
_pipe@1,
fun(Rule@1) -> erlang:element(3, Rule@1) end
),
gleam@list:unique(_pipe@2).