diff --git a/CLAUDE.md b/CLAUDE.md index 0a203a9..bb17bea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -637,3 +637,4 @@ event = %Event{name: "go"} - no need to mention code quality improvements as they are expected (unless the functional change is about code quality improvements) - commit titles should be less than 50 characters and be in the simple present tense (active voice) - examples: 'Adds ..., Fixes ...' - commit descriptions should wrap at about 72 characters and also be in the simple present tense (active voice) +- When writing functions that take a state_chart, put the state_chart as the first argument to help with threading the state_chart through code execution using Elixir pipelines diff --git a/lib/statifier/actions/action_executor.ex b/lib/statifier/actions/action_executor.ex index 2458102..5ac7b6a 100644 --- a/lib/statifier/actions/action_executor.ex +++ b/lib/statifier/actions/action_executor.ex @@ -111,7 +111,7 @@ defmodule Statifier.Actions.ActionExecutor do }) # Delegate to LogAction's own execute method - LogAction.execute(log_action, state_chart) + LogAction.execute(state_chart, log_action) end defp execute_single_action(%RaiseAction{} = raise_action, state_id, phase, state_chart) do @@ -125,7 +125,7 @@ defmodule Statifier.Actions.ActionExecutor do }) # Delegate to RaiseAction's own execute method - RaiseAction.execute(raise_action, state_chart) + RaiseAction.execute(state_chart, raise_action) end defp execute_single_action(%AssignAction{} = assign_action, state_id, phase, state_chart) do @@ -140,7 +140,7 @@ defmodule Statifier.Actions.ActionExecutor do }) # Use the AssignAction's execute method which handles all the logic - AssignAction.execute(assign_action, state_chart) + AssignAction.execute(state_chart, assign_action) end defp execute_single_action(%IfAction{} = if_action, state_id, phase, state_chart) do @@ -154,7 +154,7 @@ defmodule Statifier.Actions.ActionExecutor do }) # Use the IfAction's execute method which handles all the conditional logic - IfAction.execute(if_action, state_chart) + IfAction.execute(state_chart, if_action) end defp execute_single_action(%ForeachAction{} = foreach_action, state_id, phase, state_chart) do @@ -171,7 +171,7 @@ defmodule Statifier.Actions.ActionExecutor do }) # Use the ForeachAction's execute method which handles all the iteration logic - ForeachAction.execute(foreach_action, state_chart) + ForeachAction.execute(state_chart, foreach_action) end defp execute_single_action(%SendAction{} = send_action, state_id, phase, state_chart) do @@ -186,7 +186,7 @@ defmodule Statifier.Actions.ActionExecutor do }) # Use the SendAction's execute method which handles all the send logic - SendAction.execute(send_action, state_chart) + SendAction.execute(state_chart, send_action) end defp execute_single_action(unknown_action, state_id, phase, state_chart) do @@ -204,18 +204,14 @@ defmodule Statifier.Actions.ActionExecutor do end # Execute actions for a single transition - defp execute_single_transition_actions(state_chart, transition) do - case transition.actions do - [] -> - state_chart - - actions -> - # Execute each action in the transition - actions - |> Enum.reduce(state_chart, fn action, acc_state_chart -> - execute_single_action(acc_state_chart, action) - end) - end + defp execute_single_transition_actions(state_chart, %{actions: []}), + do: state_chart + + defp execute_single_transition_actions(state_chart, %{actions: actions}) do + # Execute each action in the transition + Enum.reduce(actions, state_chart, fn action, acc_state_chart -> + execute_single_action(acc_state_chart, action) + end) end # Create a summary of actions for logging diff --git a/lib/statifier/actions/assign_action.ex b/lib/statifier/actions/assign_action.ex index 763821c..165fdd2 100644 --- a/lib/statifier/actions/assign_action.ex +++ b/lib/statifier/actions/assign_action.ex @@ -44,7 +44,7 @@ defmodule Statifier.Actions.AssignAction do @doc """ Create a new AssignAction from parsed attributes. - The expr is compiled for performance during creation. + The expr will be compiled during document validation for performance. ## Examples @@ -53,32 +53,22 @@ defmodule Statifier.Actions.AssignAction do "user.name" iex> action.expr "'John'" - iex> is_list(action.compiled_expr) - true + iex> action.compiled_expr + nil """ @spec new(String.t(), String.t(), map() | nil) :: t() def new(location, expr, source_location \\ nil) when is_binary(location) and is_binary(expr) do - # Pre-compile expression for performance - compiled_expr = compile_safe(expr, :expression) - %__MODULE__{ location: location, expr: expr, - compiled_expr: compiled_expr, + # Will be compiled during validation + compiled_expr: nil, source_location: source_location } end - # Safely compile expressions, returning nil on error - defp compile_safe(expr, _type) do - case Evaluator.compile_expression(expr) do - {:ok, compiled} -> compiled - {:error, _reason} -> nil - end - end - @doc """ Execute the assign action by evaluating the expression and assigning to the location. @@ -89,8 +79,8 @@ defmodule Statifier.Actions.AssignAction do Returns the updated StateChart with modified data model. """ - @spec execute(t(), StateChart.t()) :: StateChart.t() - def execute(%__MODULE__{} = assign_action, %StateChart{} = state_chart) do + @spec execute(StateChart.t(), t()) :: StateChart.t() + def execute(%StateChart{} = state_chart, %__MODULE__{} = assign_action) do # Use Evaluator.evaluate_and_assign with pre-compiled expression if available case Evaluator.evaluate_and_assign( assign_action.location, diff --git a/lib/statifier/actions/foreach_action.ex b/lib/statifier/actions/foreach_action.ex index 3acd44b..6b6e2c8 100644 --- a/lib/statifier/actions/foreach_action.ex +++ b/lib/statifier/actions/foreach_action.ex @@ -74,15 +74,13 @@ defmodule Statifier.Actions.ForeachAction do @spec new(String.t(), String.t(), String.t() | nil, [term()], map() | nil) :: t() def new(array, item, index \\ nil, actions, source_location \\ nil) when is_binary(array) and is_binary(item) and is_list(actions) do - # Pre-compile array expression for performance - compiled_array = compile_safe(array) - %__MODULE__{ array: array, item: item, index: index, actions: actions, - compiled_array: compiled_array, + # Will be compiled during validation + compiled_array: nil, source_location: source_location } end @@ -101,8 +99,8 @@ defmodule Statifier.Actions.ForeachAction do Returns the updated StateChart. """ - @spec execute(t(), StateChart.t()) :: StateChart.t() - def execute(%__MODULE__{} = foreach_action, %StateChart{} = state_chart) do + @spec execute(StateChart.t(), t()) :: StateChart.t() + def execute(%StateChart{} = state_chart, %__MODULE__{} = foreach_action) do # Step 1: Evaluate array expression case evaluate_array(foreach_action, state_chart) do {:ok, collection} when is_list(collection) -> @@ -124,14 +122,6 @@ defmodule Statifier.Actions.ForeachAction do # Private functions - # Safely compile expressions, returning nil on error - defp compile_safe(expr) when is_binary(expr) do - case Evaluator.compile_expression(expr) do - {:ok, compiled} -> compiled - {:error, _reason} -> nil - end - end - # Evaluate the array expression to get the collection defp evaluate_array(%{compiled_array: compiled_array, array: array_expr}, state_chart) do case Evaluator.evaluate_value(compiled_array || array_expr, state_chart) do diff --git a/lib/statifier/actions/if_action.ex b/lib/statifier/actions/if_action.ex index 8b05ced..33a0539 100644 --- a/lib/statifier/actions/if_action.ex +++ b/lib/statifier/actions/if_action.ex @@ -72,15 +72,14 @@ defmodule Statifier.Actions.IfAction do """ @spec new([conditional_block()], map() | nil) :: t() def new(conditional_blocks, source_location \\ nil) when is_list(conditional_blocks) do - # Pre-compile conditions for performance - compiled_blocks = + # Add nil compiled_cond to blocks - will be compiled during validation + blocks_with_nil_compiled = Enum.map(conditional_blocks, fn block -> - compiled_cond = compile_safe(block[:cond]) - Map.put(block, :compiled_cond, compiled_cond) + Map.put(block, :compiled_cond, nil) end) %__MODULE__{ - conditional_blocks: compiled_blocks, + conditional_blocks: blocks_with_nil_compiled, source_location: source_location } end @@ -95,23 +94,13 @@ defmodule Statifier.Actions.IfAction do 4. Return the updated StateChart """ - @spec execute(t(), StateChart.t()) :: StateChart.t() - def execute(%__MODULE__{} = if_action, %StateChart{} = state_chart) do + @spec execute(StateChart.t(), t()) :: StateChart.t() + def execute(%StateChart{} = state_chart, %__MODULE__{} = if_action) do execute_conditional_blocks(if_action.conditional_blocks, state_chart) end # Private functions - # Safely compile expressions, returning nil on error - defp compile_safe(nil), do: nil - - defp compile_safe(expr) when is_binary(expr) do - case Evaluator.compile_expression(expr) do - {:ok, compiled} -> compiled - {:error, _reason} -> nil - end - end - # Process conditional blocks in order until one condition is true defp execute_conditional_blocks([], state_chart), do: state_chart @@ -130,9 +119,24 @@ defmodule Statifier.Actions.IfAction do # Determine if a conditional block should be executed defp should_execute_block?(%{type: :else}, _state_chart), do: true - defp should_execute_block?(%{type: type, compiled_cond: compiled_cond}, state_chart) + defp should_execute_block?(%{type: type, compiled_cond: compiled_cond, cond: cond}, state_chart) when type in [:if, :elseif] do - Evaluator.evaluate_condition(compiled_cond, state_chart) + case compiled_cond do + nil when is_binary(cond) -> + # Fallback to runtime compilation for expressions not compiled during validation + case Evaluator.compile_expression(cond) do + {:ok, compiled} -> Evaluator.evaluate_condition(compiled, state_chart) + {:error, _reason} -> false + end + + nil -> + # No condition provided + false + + compiled -> + # Use pre-compiled condition + Evaluator.evaluate_condition(compiled, state_chart) + end end # Execute all actions within a conditional block diff --git a/lib/statifier/actions/log_action.ex b/lib/statifier/actions/log_action.ex index c314002..58a3238 100644 --- a/lib/statifier/actions/log_action.ex +++ b/lib/statifier/actions/log_action.ex @@ -37,8 +37,8 @@ defmodule Statifier.Actions.LogAction do @doc """ Executes the log action by evaluating the expression and logging the result. """ - @spec execute(t(), Statifier.StateChart.t()) :: Statifier.StateChart.t() - def execute(%__MODULE__{} = log_action, state_chart) do + @spec execute(Statifier.StateChart.t(), t()) :: Statifier.StateChart.t() + def execute(state_chart, %__MODULE__{} = log_action) do # Use Evaluator to handle quoted strings and expressions properly message = evaluate_log_expression(log_action.expr, state_chart) label = log_action.label || "Log" diff --git a/lib/statifier/actions/raise_action.ex b/lib/statifier/actions/raise_action.ex index 5494e79..dfec57d 100644 --- a/lib/statifier/actions/raise_action.ex +++ b/lib/statifier/actions/raise_action.ex @@ -24,8 +24,8 @@ defmodule Statifier.Actions.RaiseAction do @doc """ Executes the raise action by creating an internal event and adding it to the state chart's event queue. """ - @spec execute(t(), Statifier.StateChart.t()) :: Statifier.StateChart.t() - def execute(%__MODULE__{} = raise_action, state_chart) do + @spec execute(Statifier.StateChart.t(), t()) :: Statifier.StateChart.t() + def execute(state_chart, %__MODULE__{} = raise_action) do event_name = raise_action.event || "anonymous_event" # Create internal event and enqueue it internal_event = %Event{ diff --git a/lib/statifier/actions/send_action.ex b/lib/statifier/actions/send_action.ex index d60851b..4cab86d 100644 --- a/lib/statifier/actions/send_action.ex +++ b/lib/statifier/actions/send_action.ex @@ -17,14 +17,18 @@ defmodule Statifier.Actions.SendAction do @type t :: %__MODULE__{ event: String.t() | nil, event_expr: String.t() | nil, + compiled_event_expr: term() | nil, target: String.t() | nil, target_expr: String.t() | nil, + compiled_target_expr: term() | nil, type: String.t() | nil, type_expr: String.t() | nil, + compiled_type_expr: term() | nil, id: String.t() | nil, id_location: String.t() | nil, delay: String.t() | nil, delay_expr: String.t() | nil, + compiled_delay_expr: term() | nil, namelist: String.t() | nil, params: [Statifier.Actions.SendParam.t()], content: Statifier.Actions.SendContent.t() | nil, @@ -36,14 +40,20 @@ defmodule Statifier.Actions.SendAction do :event, # Expression for event name :event_expr, + # Compiled event expression + :compiled_event_expr, # Static target URI :target, # Expression for target :target_expr, + # Compiled target expression + :compiled_target_expr, # Static processor type :type, # Expression for processor type :type_expr, + # Compiled type expression + :compiled_type_expr, # Static send ID :id, # Location to store generated ID @@ -52,6 +62,8 @@ defmodule Statifier.Actions.SendAction do :delay, # Expression for delay :delay_expr, + # Compiled delay expression + :compiled_delay_expr, # Space-separated variable names :namelist, # List of SendParam structs @@ -66,8 +78,8 @@ defmodule Statifier.Actions.SendAction do Executes the send action by creating an event and routing it to the appropriate destination. For Phase 1, only supports immediate internal sends. """ - @spec execute(t(), Statifier.StateChart.t()) :: Statifier.StateChart.t() - def execute(%__MODULE__{} = send_action, state_chart) do + @spec execute(Statifier.StateChart.t(), t()) :: Statifier.StateChart.t() + def execute(state_chart, %__MODULE__{} = send_action) do # Phase 1: Only support immediate internal sends {:ok, event_name, target_uri, _delay} = evaluate_send_parameters(send_action, state_chart) @@ -99,53 +111,89 @@ defmodule Statifier.Actions.SendAction do end defp evaluate_event_name(send_action, state_chart) do - cond do - send_action.event != nil -> - send_action.event - - send_action.event_expr != nil -> - case evaluate_expression_value(send_action.event_expr, state_chart) do - {:ok, value} when is_binary(value) -> value - {:ok, value} -> to_string(value) - {:error, _reason} -> "anonymous_event" - end - - true -> - "anonymous_event" - end + state_chart + |> evaluate_attribute_with_expr( + send_action.event, + send_action.compiled_event_expr, + send_action.event_expr, + "anonymous_event" + ) end defp evaluate_target(send_action, state_chart) do - cond do - send_action.target != nil -> - send_action.target - - send_action.target_expr != nil -> - case evaluate_expression_value(send_action.target_expr, state_chart) do - {:ok, value} when is_binary(value) -> value - {:ok, value} -> to_string(value) - {:error, _reason} -> "#_internal" - end - - true -> - "#_internal" - end + state_chart + |> evaluate_attribute_with_expr( + send_action.target, + send_action.compiled_target_expr, + send_action.target_expr, + "#_internal" + ) end defp evaluate_delay(send_action, state_chart) do - cond do - send_action.delay != nil -> - send_action.delay - - send_action.delay_expr != nil -> - case evaluate_expression_value(send_action.delay_expr, state_chart) do - {:ok, value} when is_binary(value) -> value - {:ok, value} -> to_string(value) - {:error, _reason} -> "0s" - end + state_chart + |> evaluate_attribute_with_expr( + send_action.delay, + send_action.compiled_delay_expr, + send_action.delay_expr, + "0s" + ) + end - true -> - "0s" + # Common helper for evaluating attributes that can be static or expressions + defp evaluate_attribute_with_expr( + _state_chart, + static_value, + _compiled_expr, + _expr_string, + _default_value + ) + when not is_nil(static_value), + do: static_value + + defp evaluate_attribute_with_expr( + state_chart, + _static_value, + compiled_expr, + _expr_string, + default_value + ) + when not is_nil(compiled_expr), + do: evaluate_compiled_expression(state_chart, compiled_expr, default_value) + + defp evaluate_attribute_with_expr( + state_chart, + _static_value, + _compiled_expr, + expr_string, + default_value + ) + when not is_nil(expr_string), + do: evaluate_runtime_expression(state_chart, expr_string, default_value) + + defp evaluate_attribute_with_expr( + _state_chart, + _static_value, + _compiled_expr, + _expr_string, + default_value + ), + do: default_value + + defp evaluate_compiled_expression(state_chart, compiled_expr, default_value) do + case Evaluator.evaluate_value(compiled_expr, state_chart) do + {:ok, value} when is_binary(value) -> value + {:ok, value} -> to_string(value) + {:error, _reason} -> default_value + end + end + + defp evaluate_runtime_expression(state_chart, expr_string, default_value) do + # Fallback to runtime compilation for edge cases + case evaluate_expression_value(expr_string, state_chart) do + {:ok, value} when is_binary(value) -> value + {:ok, value} -> to_string(value) + {:error, _reason} -> default_value end end diff --git a/lib/statifier/feature_detector.ex b/lib/statifier/feature_detector.ex index d0561e2..1bb4ea2 100644 --- a/lib/statifier/feature_detector.ex +++ b/lib/statifier/feature_detector.ex @@ -83,8 +83,8 @@ defmodule Statifier.FeatureDetector do # Advanced attributes (unsupported) send_idlocation: :unsupported, - event_expressions: :unsupported, - target_expressions: :unsupported, + event_expressions: :supported, + target_expressions: :supported, # Wildcard and pattern events (supported) wildcard_events: :supported, diff --git a/lib/statifier/parser/scxml/element_builder.ex b/lib/statifier/parser/scxml/element_builder.ex index 6328ad8..805353a 100644 --- a/lib/statifier/parser/scxml/element_builder.ex +++ b/lib/statifier/parser/scxml/element_builder.ex @@ -13,8 +13,7 @@ defmodule Statifier.Parser.SCXML.ElementBuilder do Actions.RaiseAction, Actions.SendAction, Actions.SendContent, - Actions.SendParam, - Evaluator + Actions.SendParam } alias Statifier.Parser.SCXML.LocationTracker @@ -225,17 +224,6 @@ defmodule Statifier.Parser.SCXML.ElementBuilder do cond_attr = get_attr_value(attrs_map, "cond") - # Compile condition if present - compiled_cond = - case Evaluator.compile_expression(cond_attr) do - {:ok, compiled} -> - compiled - - {:error, _reason} -> - # Log compilation error for debugging - nil - end - # Parse target - handle space-separated multiple targets # Always return a list, empty list means no targets targets = @@ -252,7 +240,8 @@ defmodule Statifier.Parser.SCXML.ElementBuilder do event: get_attr_value(attrs_map, "event"), targets: targets, cond: cond_attr, - compiled_cond: compiled_cond, + # Will be compiled during validation + compiled_cond: nil, type: get_attr_value(attrs_map, "type"), document_order: document_order, # Location information diff --git a/lib/statifier/validator.ex b/lib/statifier/validator.ex index 6c53bb6..22d5478 100644 --- a/lib/statifier/validator.ex +++ b/lib/statifier/validator.ex @@ -11,6 +11,7 @@ defmodule Statifier.Validator do alias Statifier.{Document, HierarchyCache} alias Statifier.Validator.{ + ExpressionCompiler, HistoryStateValidator, InitialStateValidator, ReachabilityAnalyzer, @@ -67,24 +68,36 @@ defmodule Statifier.Validator do |> InitialStateValidator.validate_hierarchical_consistency(document) |> InitialStateValidator.validate_initial_state_hierarchy(document) - final_document = + {final_result, final_document} = case validated_result.errors do [] -> # Only optimize valid documents # Build lookup maps first (required for StateHierarchy functions) - # Then build hierarchy cache (uses StateHierarchy functions) - document - |> Document.build_lookup_maps() - |> build_hierarchy_cache() - |> mark_as_validated() + # Then compile expressions and build hierarchy cache + {compilation_warnings, compiled_document} = + ExpressionCompiler.compile_document(document) + + optimized_document = + compiled_document + |> Document.build_lookup_maps() + |> build_hierarchy_cache() + |> mark_as_validated() + + # Add compilation warnings to validation result + final_result = %{ + validated_result + | warnings: validated_result.warnings ++ compilation_warnings + } + + {final_result, optimized_document} _errors -> # Don't waste time optimizing invalid documents # But still mark as processed through validation - mark_as_validated(document) + {validated_result, mark_as_validated(document)} end - {validated_result, final_document} + {final_result, final_document} end # Build hierarchy cache for performance optimization diff --git a/lib/statifier/validator/expression_compiler.ex b/lib/statifier/validator/expression_compiler.ex new file mode 100644 index 0000000..000e983 --- /dev/null +++ b/lib/statifier/validator/expression_compiler.ex @@ -0,0 +1,233 @@ +defmodule Statifier.Validator.ExpressionCompiler do + @moduledoc """ + Compiles expressions in SCXML documents for performance optimization. + + This module handles compilation of all expression types during document validation, + collecting compilation errors as warnings for developer feedback. Expression + compilation happens after structural validation but before runtime optimization. + + ## Compilation Strategy + + - **Transitions**: Compile `cond` attributes for condition evaluation + - **SendAction**: Compile `event_expr`, `target_expr`, `type_expr`, `delay_expr` + - **AssignAction**: Compile `expr` attribute for value evaluation + - **IfAction**: Compile condition expressions in conditional blocks + - **ForeachAction**: Compile `array` expression for iteration + + ## Warning Collection + + Compilation errors are collected as validation warnings with detailed context: + - Expression content and context (e.g., "transition condition") + - Source location information (line/column when available) + - Compilation error details for debugging + + ## Usage + + # Compile expressions in a document during validation + {warnings, compiled_document} = ExpressionCompiler.compile_document(document) + + """ + + alias Statifier.{Document, Evaluator} + + @doc """ + Compile all expressions in a document and collect warnings. + + Returns a tuple with compilation warnings and the document with compiled expressions. + """ + @spec compile_document(Document.t()) :: {[String.t()], Document.t()} + def compile_document(%Document{} = document) do + # Compile expressions in all states recursively + {warnings, compiled_states} = compile_states(document.states, []) + + compiled_document = %{document | states: compiled_states} + {warnings, compiled_document} + end + + # Compile expressions in states recursively with warning collection + defp compile_states(states, warnings) do + {all_warnings, compiled_states} = + Enum.reduce(states, {warnings, []}, fn state, {acc_warnings, acc_states} -> + # Compile transitions for this state + {transition_warnings, compiled_transitions} = + compile_transitions(state.transitions, []) + + # Compile actions in this state + {onentry_warnings, compiled_onentry_actions} = compile_actions(state.onentry_actions, []) + {onexit_warnings, compiled_onexit_actions} = compile_actions(state.onexit_actions, []) + + # Recursively compile nested states + {nested_warnings, compiled_nested_states} = compile_states(state.states, []) + + # Combine all warnings and create compiled state + state_warnings = + transition_warnings ++ onentry_warnings ++ onexit_warnings ++ nested_warnings + + compiled_state = %{ + state + | transitions: compiled_transitions, + onentry_actions: compiled_onentry_actions, + onexit_actions: compiled_onexit_actions, + states: compiled_nested_states + } + + {acc_warnings ++ state_warnings, [compiled_state | acc_states]} + end) + + {all_warnings, Enum.reverse(compiled_states)} + end + + # Compile expressions in transitions with warning collection + defp compile_transitions(transitions, warnings) do + {all_warnings, compiled_transitions} = + Enum.reduce(transitions, {warnings, []}, fn transition, {acc_warnings, acc_transitions} -> + {warning, compiled_transition} = compile_transition(transition) + new_warnings = if warning, do: [warning | acc_warnings], else: acc_warnings + {new_warnings, [compiled_transition | acc_transitions]} + end) + + {all_warnings, Enum.reverse(compiled_transitions)} + end + + # Compile expressions in a single transition + defp compile_transition(transition) do + {warning, compiled_cond} = + compile_expression_with_warning( + transition.cond, + "transition condition", + transition.source_location + ) + + compiled_transition = %{transition | compiled_cond: compiled_cond} + {warning, compiled_transition} + end + + # Compile expressions in actions with warning collection + defp compile_actions(actions, warnings) do + {all_warnings, compiled_actions} = + Enum.reduce(actions, {warnings, []}, fn action, {acc_warnings, acc_actions} -> + {action_warnings, compiled_action} = compile_action(action) + {acc_warnings ++ action_warnings, [compiled_action | acc_actions]} + end) + + {all_warnings, Enum.reverse(compiled_actions)} + end + + # Compile expressions in individual actions based on action type + defp compile_action(%Statifier.Actions.SendAction{} = action) do + {warnings, compiled_fields} = + [ + compile_expression_with_warning( + action.event_expr, + "send action event expression", + action.source_location + ), + compile_expression_with_warning( + action.target_expr, + "send action target expression", + action.source_location + ), + compile_expression_with_warning( + action.type_expr, + "send action type expression", + action.source_location + ), + compile_expression_with_warning( + action.delay_expr, + "send action delay expression", + action.source_location + ) + ] + |> Enum.unzip() + + compiled_action = %{ + action + | compiled_event_expr: Enum.at(compiled_fields, 0), + compiled_target_expr: Enum.at(compiled_fields, 1), + compiled_type_expr: Enum.at(compiled_fields, 2), + compiled_delay_expr: Enum.at(compiled_fields, 3) + } + + filtered_warnings = Enum.filter(warnings, &(&1 != nil)) + {filtered_warnings, compiled_action} + end + + defp compile_action(%Statifier.Actions.AssignAction{} = action) do + {warning, compiled_expr} = + compile_expression_with_warning( + action.expr, + "assign action expression", + action.source_location + ) + + compiled_action = %{action | compiled_expr: compiled_expr} + warnings = if warning, do: [warning], else: [] + {warnings, compiled_action} + end + + defp compile_action(%Statifier.Actions.IfAction{} = action) do + {warnings, compiled_blocks} = + Enum.reduce(action.conditional_blocks, {[], []}, fn block, {acc_warnings, acc_blocks} -> + {warning, compiled_cond} = + compile_expression_with_warning( + block[:cond], + "if action condition", + action.source_location + ) + + compiled_block = Map.put(block, :compiled_cond, compiled_cond) + new_warnings = if warning, do: [warning | acc_warnings], else: acc_warnings + {new_warnings, [compiled_block | acc_blocks]} + end) + + compiled_action = %{action | conditional_blocks: Enum.reverse(compiled_blocks)} + {Enum.reverse(warnings), compiled_action} + end + + defp compile_action(%Statifier.Actions.ForeachAction{} = action) do + {warning, compiled_array} = + compile_expression_with_warning( + action.array, + "foreach action array expression", + action.source_location + ) + + compiled_action = %{action | compiled_array: compiled_array} + warnings = if warning, do: [warning], else: [] + {warnings, compiled_action} + end + + # Handle other action types - pass through unchanged + defp compile_action(action), do: {[], action} + + # Compile expression with warning collection + defp compile_expression_with_warning(nil, _context, _location), do: {nil, nil} + + defp compile_expression_with_warning(expression, context, location) + when is_binary(expression) do + case Evaluator.compile_expression(expression) do + {:ok, compiled} -> + {nil, compiled} + + {:error, reason} -> + warning = format_compilation_warning(expression, context, reason, location) + {warning, nil} + end + end + + # Format compilation warning with context and location + defp format_compilation_warning(expression, context, reason, location) do + base_message = "Failed to compile #{context}: '#{expression}' (#{inspect(reason)})" + + case location do + %{line: line, column: column} -> + "#{base_message} at line #{line}, column #{column}" + + %{line: line} -> + "#{base_message} at line #{line}" + + _no_location -> + base_message + end + end +end diff --git a/test/statifier/actions/assign_action_test.exs b/test/statifier/actions/assign_action_test.exs index be0f9c2..05aff81 100644 --- a/test/statifier/actions/assign_action_test.exs +++ b/test/statifier/actions/assign_action_test.exs @@ -37,7 +37,7 @@ defmodule Statifier.Actions.AssignActionTest do test "executes simple assignment", %{state_chart: state_chart} do action = AssignAction.new("userName", "'John Doe'") - result = AssignAction.execute(action, state_chart) + result = AssignAction.execute(state_chart, action) assert %StateChart{datamodel: %{"userName" => "John Doe"}} = result end @@ -45,7 +45,7 @@ defmodule Statifier.Actions.AssignActionTest do test "executes nested assignment", %{state_chart: state_chart} do action = AssignAction.new("user.profile.name", "'Jane Smith'") - result = AssignAction.execute(action, state_chart) + result = AssignAction.execute(state_chart, action) expected_data = %{"user" => %{"profile" => %{"name" => "Jane Smith"}}} assert %StateChart{datamodel: ^expected_data} = result @@ -55,7 +55,7 @@ defmodule Statifier.Actions.AssignActionTest do state_chart = %{state_chart | datamodel: %{"counter" => 5}} action = AssignAction.new("counter", "counter + 3") - result = AssignAction.execute(action, state_chart) + result = AssignAction.execute(state_chart, action) assert %StateChart{datamodel: %{"counter" => 8}} = result end @@ -64,7 +64,7 @@ defmodule Statifier.Actions.AssignActionTest do state_chart = %{state_chart | datamodel: %{"users" => %{}}} action = AssignAction.new("users['john'].active", "true") - result = AssignAction.execute(action, state_chart) + result = AssignAction.execute(state_chart, action) expected_data = %{"users" => %{"john" => %{"active" => true}}} assert %StateChart{datamodel: ^expected_data} = result @@ -75,7 +75,7 @@ defmodule Statifier.Actions.AssignActionTest do state_chart = %{state_chart | current_event: event} action = AssignAction.new("lastUpdate", "_event.data.newValue") - result = AssignAction.execute(action, state_chart) + result = AssignAction.execute(state_chart, action) assert %StateChart{datamodel: %{"lastUpdate" => "updated"}} = result end @@ -84,7 +84,7 @@ defmodule Statifier.Actions.AssignActionTest do state_chart = %{state_chart | datamodel: %{"existing" => "value", "counter" => 10}} action = AssignAction.new("newField", "'new value'") - result = AssignAction.execute(action, state_chart) + result = AssignAction.execute(state_chart, action) expected_data = %{ "existing" => "value", @@ -107,7 +107,7 @@ defmodule Statifier.Actions.AssignActionTest do state_chart = %{state_chart | datamodel: initial_data} action = AssignAction.new("user.settings.theme", "'dark'") - result = AssignAction.execute(action, state_chart) + result = AssignAction.execute(state_chart, action) expected_data = %{ "user" => %{ @@ -124,7 +124,7 @@ defmodule Statifier.Actions.AssignActionTest do action = AssignAction.new("invalid [[ syntax", "'value'") # Should not crash, should log error and return state chart with log entry - result = AssignAction.execute(action, state_chart) + result = AssignAction.execute(state_chart, action) # Datamodel should be unchanged, but logs should contain error entry assert result.datamodel == state_chart.datamodel @@ -140,7 +140,7 @@ defmodule Statifier.Actions.AssignActionTest do action = AssignAction.new("result", "undefined_variable + 1") # Should not crash, should log error and return state chart with log entry - result = AssignAction.execute(action, state_chart) + result = AssignAction.execute(state_chart, action) # Datamodel should be unchanged, but logs should contain error entry assert result.datamodel == state_chart.datamodel @@ -157,7 +157,7 @@ defmodule Statifier.Actions.AssignActionTest do # For now, we test with a simple string that predictor can handle action = AssignAction.new("config.settings", "'complex_value'") - result = AssignAction.execute(action, state_chart) + result = AssignAction.execute(state_chart, action) expected_data = %{"config" => %{"settings" => "complex_value"}} assert %StateChart{datamodel: ^expected_data} = result @@ -171,31 +171,32 @@ defmodule Statifier.Actions.AssignActionTest do # This tests that we have access to state machine context during evaluation action = AssignAction.new("stateCount", "counter + 1") - result = AssignAction.execute(action, state_chart) + result = AssignAction.execute(state_chart, action) assert %StateChart{datamodel: %{"counter" => 0, "stateCount" => 1}} = result end - test "pre-compiles expressions during creation for performance" do + test "expressions are compiled during validation, not creation" do action = AssignAction.new("user.profile.name", "'John Doe'") - # Verify that expression is pre-compiled - assert not is_nil(action.compiled_expr) - assert is_list(action.compiled_expr) + # Verify that expression is not compiled during creation + assert is_nil(action.compiled_expr) # Verify original strings are preserved assert action.location == "user.profile.name" assert action.expr == "'John Doe'" end - test "uses pre-compiled expressions for better performance", %{state_chart: state_chart} do + test "expressions work correctly with validation-time compilation", %{ + state_chart: state_chart + } do action = AssignAction.new("user.settings.theme", "'dark'") - # Verify pre-compilation occurred for expression - assert not is_nil(action.compiled_expr) + # Verify expression is not compiled during creation + assert is_nil(action.compiled_expr) - # Execute should use pre-compiled expressions internally - result = AssignAction.execute(action, state_chart) + # Execute should work with runtime compilation as fallback + result = AssignAction.execute(state_chart, action) # Verify result is correct expected_data = %{"user" => %{"settings" => %{"theme" => "dark"}}} diff --git a/test/statifier/actions/foreach_action_test.exs b/test/statifier/actions/foreach_action_test.exs index 85e15ab..5d26d20 100644 --- a/test/statifier/actions/foreach_action_test.exs +++ b/test/statifier/actions/foreach_action_test.exs @@ -34,7 +34,7 @@ defmodule Statifier.Actions.ForeachActionTest do test "executes simple foreach without actions", %{state_chart: state_chart} do action = ForeachAction.new("myArray", "item", "index", []) - result = ForeachAction.execute(action, state_chart) + result = ForeachAction.execute(state_chart, action) # The foreach should complete without errors # New variables should be declared permanently (SCXML spec) @@ -54,7 +54,7 @@ defmodule Statifier.Actions.ForeachActionTest do action = ForeachAction.new("myArray", "item", "index", [log_action]) - result = ForeachAction.execute(action, state_chart) + result = ForeachAction.execute(state_chart, action) # The foreach should complete without errors # New variables should be declared permanently (SCXML spec) @@ -73,7 +73,7 @@ defmodule Statifier.Actions.ForeachActionTest do action = ForeachAction.new("nonExistentArray", "item", "index", []) - result = ForeachAction.execute(action, state_chart) + result = ForeachAction.execute(state_chart, action) # Should add error.execution event to internal queue assert length(result.internal_queue) > 0 @@ -88,7 +88,7 @@ defmodule Statifier.Actions.ForeachActionTest do action = ForeachAction.new("notArray", "item", "index", []) - result = ForeachAction.execute(action, state_chart) + result = ForeachAction.execute(state_chart, action) # Should add error.execution event to internal queue assert length(result.internal_queue) > 0 @@ -104,7 +104,7 @@ defmodule Statifier.Actions.ForeachActionTest do assert !Map.has_key?(state_chart.datamodel, "newItem") assert !Map.has_key?(state_chart.datamodel, "newIndex") - result = ForeachAction.execute(action, state_chart) + result = ForeachAction.execute(state_chart, action) # Variables should now be declared permanently with final iteration values assert Map.has_key?(result.datamodel, "newItem") @@ -124,7 +124,7 @@ defmodule Statifier.Actions.ForeachActionTest do action = ForeachAction.new("myArray", "existingVar", "existingIndex", []) - result = ForeachAction.execute(action, state_chart) + result = ForeachAction.execute(state_chart, action) # Existing variables should be restored to original values assert result.datamodel["existingVar"] == "original" @@ -134,7 +134,7 @@ defmodule Statifier.Actions.ForeachActionTest do test "handles foreach without index parameter", %{state_chart: state_chart} do action = ForeachAction.new("myArray", "item", nil, []) - result = ForeachAction.execute(action, state_chart) + result = ForeachAction.execute(state_chart, action) # Only item should be declared, not index assert Map.has_key?(result.datamodel, "item") @@ -149,7 +149,7 @@ defmodule Statifier.Actions.ForeachActionTest do action = ForeachAction.new("invalidExpression(", "item", nil, []) # Should still execute (compilation errors are handled gracefully) - result = ForeachAction.execute(action, state_chart) + result = ForeachAction.execute(state_chart, action) # Should generate error.execution event assert length(result.internal_queue) > 0 @@ -162,7 +162,7 @@ defmodule Statifier.Actions.ForeachActionTest do action = ForeachAction.new("myArray", "item", nil, [bad_assign]) - result = ForeachAction.execute(action, state_chart) + result = ForeachAction.execute(state_chart, action) # Should handle the exception and continue # The action should execute and handle any errors gracefully @@ -178,7 +178,7 @@ defmodule Statifier.Actions.ForeachActionTest do # The Evaluator.evaluate_and_assign should fail for this action = ForeachAction.new("badArray", "", nil, []) - result = ForeachAction.execute(action, state_chart) + result = ForeachAction.execute(state_chart, action) # Should handle the assignment failure gracefully assert is_map(result) @@ -195,7 +195,7 @@ defmodule Statifier.Actions.ForeachActionTest do # This will trigger error paths in the set_foreach_variable function action = ForeachAction.new("problematicArray", "item..with..dots", nil, []) - result = ForeachAction.execute(action, state_chart) + result = ForeachAction.execute(state_chart, action) # Should handle problematic variable names gracefully assert is_map(result) @@ -211,7 +211,7 @@ defmodule Statifier.Actions.ForeachActionTest do # This should trigger the {:error, reason} path in set_foreach_variable action = ForeachAction.new("testArray", "123invalid", nil, []) - result = ForeachAction.execute(action, state_chart) + result = ForeachAction.execute(state_chart, action) # Should complete without crashing even with assignment failures assert is_map(result) @@ -239,7 +239,7 @@ defmodule Statifier.Actions.ForeachActionTest do # This tries to trigger the error path in set_foreach_variable action = ForeachAction.new("testArray", "0invalid_var_name", nil, []) - result = ForeachAction.execute(action, state_chart) + result = ForeachAction.execute(state_chart, action) # Should complete execution despite assignment errors assert is_map(result) @@ -258,7 +258,7 @@ defmodule Statifier.Actions.ForeachActionTest do # Use invalid Elixir variable syntax to trigger evaluator error action = ForeachAction.new("errorArray", "@invalid", nil, []) - result = ForeachAction.execute(action, state_chart) + result = ForeachAction.execute(state_chart, action) # Should handle the error and return state_chart from error path assert is_map(result) diff --git a/test/statifier/actions/if_action_test.exs b/test/statifier/actions/if_action_test.exs index a61eba5..85c7d65 100644 --- a/test/statifier/actions/if_action_test.exs +++ b/test/statifier/actions/if_action_test.exs @@ -52,7 +52,7 @@ defmodule Statifier.Actions.IfActionTest do datamodel: %{} } - result = IfAction.execute(if_action, state_chart) + result = IfAction.execute(state_chart, if_action) # Should execute the first block and assign "first" to result assert result.datamodel["result"] == "first" @@ -74,7 +74,7 @@ defmodule Statifier.Actions.IfActionTest do datamodel: %{} } - result = IfAction.execute(if_action, state_chart) + result = IfAction.execute(state_chart, if_action) # Should execute the else block and assign "second" to result assert result.datamodel["result"] == "second" @@ -98,7 +98,7 @@ defmodule Statifier.Actions.IfActionTest do datamodel: %{} } - result = IfAction.execute(if_action, state_chart) + result = IfAction.execute(state_chart, if_action) # Should execute the elseif block and assign "second" to result assert result.datamodel["result"] == "second" diff --git a/test/statifier/actions/log_action_test.exs b/test/statifier/actions/log_action_test.exs index 49df0f0..fb76189 100644 --- a/test/statifier/actions/log_action_test.exs +++ b/test/statifier/actions/log_action_test.exs @@ -210,7 +210,7 @@ defmodule Statifier.Actions.LogActionTest do state_chart = LogManager.configure_from_options(state_chart, []) # Directly execute the log action with the invalid binary - result = LogAction.execute(log_action, state_chart) + result = LogAction.execute(state_chart, log_action) # Should handle invalid UTF-8 gracefully by using inspect() assert_log_entry(result, message_contains: "Test: ") @@ -258,12 +258,12 @@ defmodule Statifier.Actions.LogActionTest do # Test with atom expression type (not binary) log_action_with_atom = %LogAction{expr: :atom_expr, label: "Atom"} - result = LogAction.execute(log_action_with_atom, state_chart) + result = LogAction.execute(state_chart, log_action_with_atom) assert_log_entry(result, message_contains: "Atom: :atom_expr") # Test with number expression type log_action_with_number = %LogAction{expr: 42, label: "Number"} - result2 = LogAction.execute(log_action_with_number, result) + result2 = LogAction.execute(result, log_action_with_number) assert_log_entry(result2, message_contains: "Number: 42") end end diff --git a/test/statifier/actions/send_action_test.exs b/test/statifier/actions/send_action_test.exs index 3bada4c..0057504 100644 --- a/test/statifier/actions/send_action_test.exs +++ b/test/statifier/actions/send_action_test.exs @@ -35,7 +35,7 @@ defmodule Statifier.Actions.SendActionTest do } state_chart = create_test_state_chart() - result = SendAction.execute(send_action, state_chart) + result = SendAction.execute(state_chart, send_action) # Should enqueue internal event assert length(result.internal_queue) == 1 @@ -53,7 +53,7 @@ defmodule Statifier.Actions.SendActionTest do } state_chart = create_test_state_chart() - result = SendAction.execute(send_action, state_chart) + result = SendAction.execute(state_chart, send_action) # Should evaluate expression and enqueue event assert length(result.internal_queue) == 1 @@ -70,7 +70,7 @@ defmodule Statifier.Actions.SendActionTest do } state_chart = create_test_state_chart() - result = SendAction.execute(send_action, state_chart) + result = SendAction.execute(state_chart, send_action) # Phase 1: External targets are not yet supported, should be logged assert Enum.empty?(result.external_queue) @@ -92,7 +92,7 @@ defmodule Statifier.Actions.SendActionTest do } state_chart = create_test_state_chart() - result = SendAction.execute(send_action, state_chart) + result = SendAction.execute(state_chart, send_action) # Should default to internal queue assert length(result.internal_queue) == 1 @@ -111,7 +111,7 @@ defmodule Statifier.Actions.SendActionTest do datamodel = %{"var1" => "value1", "var2" => 42} state_chart = create_test_state_chart(datamodel) - result = SendAction.execute(send_action, state_chart) + result = SendAction.execute(state_chart, send_action) # Should include namelist data assert length(result.internal_queue) == 1 @@ -134,7 +134,7 @@ defmodule Statifier.Actions.SendActionTest do datamodel = %{"myVar" => "locationValue"} state_chart = create_test_state_chart(datamodel) - result = SendAction.execute(send_action, state_chart) + result = SendAction.execute(state_chart, send_action) # Should include param data assert length(result.internal_queue) == 1 @@ -154,7 +154,7 @@ defmodule Statifier.Actions.SendActionTest do } state_chart = create_test_state_chart() - result = SendAction.execute(send_action, state_chart) + result = SendAction.execute(state_chart, send_action) # Should use content as event data assert length(result.internal_queue) == 1 @@ -175,7 +175,7 @@ defmodule Statifier.Actions.SendActionTest do datamodel = %{"var1" => "namelist_value"} state_chart = create_test_state_chart(datamodel) - result = SendAction.execute(send_action, state_chart) + result = SendAction.execute(state_chart, send_action) # Should combine both data sources assert length(result.internal_queue) == 1 @@ -193,7 +193,7 @@ defmodule Statifier.Actions.SendActionTest do } state_chart = create_test_state_chart() - result = SendAction.execute(send_action, state_chart) + result = SendAction.execute(state_chart, send_action) [event] = result.internal_queue assert event.name == "anonymous_event" @@ -209,7 +209,7 @@ defmodule Statifier.Actions.SendActionTest do datamodel = %{"existingVar" => "present"} state_chart = create_test_state_chart(datamodel) - result = SendAction.execute(send_action, state_chart) + result = SendAction.execute(state_chart, send_action) # Should only include existing variables assert length(result.internal_queue) == 1 @@ -225,7 +225,7 @@ defmodule Statifier.Actions.SendActionTest do } state_chart = create_test_state_chart() - result = SendAction.execute(send_action, state_chart) + result = SendAction.execute(state_chart, send_action) # Should contain log entries assert length(result.logs) > 0 @@ -243,7 +243,7 @@ defmodule Statifier.Actions.SendActionTest do } state_chart = create_test_state_chart() - result = SendAction.execute(send_action, state_chart) + result = SendAction.execute(state_chart, send_action) # Should evaluate target expression and send internally assert length(result.internal_queue) == 1 @@ -260,7 +260,7 @@ defmodule Statifier.Actions.SendActionTest do } state_chart = create_test_state_chart() - result = SendAction.execute(send_action, state_chart) + result = SendAction.execute(state_chart, send_action) # Should handle invalid expression (evaluates to :undefined which becomes "undefined") # Since "undefined" is not "#_internal", it's treated as external target @@ -281,7 +281,7 @@ defmodule Statifier.Actions.SendActionTest do } state_chart = create_test_state_chart() - result = SendAction.execute(send_action, state_chart) + result = SendAction.execute(state_chart, send_action) # Should handle invalid expression (evaluates to :undefined which becomes "undefined") assert length(result.internal_queue) == 1 @@ -298,7 +298,7 @@ defmodule Statifier.Actions.SendActionTest do } state_chart = create_test_state_chart() - result = SendAction.execute(send_action, state_chart) + result = SendAction.execute(state_chart, send_action) # Should convert non-string to string assert length(result.internal_queue) == 1 @@ -315,7 +315,7 @@ defmodule Statifier.Actions.SendActionTest do } state_chart = create_test_state_chart() - result = SendAction.execute(send_action, state_chart) + result = SendAction.execute(state_chart, send_action) # Should convert non-string target to string and treat as external assert Enum.empty?(result.internal_queue) @@ -337,7 +337,7 @@ defmodule Statifier.Actions.SendActionTest do } state_chart = create_test_state_chart() - result1 = SendAction.execute(send_action1, state_chart) + result1 = SendAction.execute(state_chart, send_action1) # Should still send immediately in Phase 1 (delay not yet implemented) assert length(result1.internal_queue) == 1 @@ -350,7 +350,7 @@ defmodule Statifier.Actions.SendActionTest do params: [] } - result2 = SendAction.execute(send_action2, result1) + result2 = SendAction.execute(result1, send_action2) assert length(result2.internal_queue) == 2 # Test delay_expr evaluation error @@ -361,7 +361,7 @@ defmodule Statifier.Actions.SendActionTest do params: [] } - result3 = SendAction.execute(send_action3, result2) + result3 = SendAction.execute(result2, send_action3) assert length(result3.internal_queue) == 3 end end @@ -378,7 +378,7 @@ defmodule Statifier.Actions.SendActionTest do } state_chart = create_test_state_chart() - result = SendAction.execute(send_action, state_chart) + result = SendAction.execute(state_chart, send_action) assert length(result.internal_queue) == 1 [event] = result.internal_queue @@ -396,7 +396,7 @@ defmodule Statifier.Actions.SendActionTest do } state_chart = create_test_state_chart() - result = SendAction.execute(send_action, state_chart) + result = SendAction.execute(state_chart, send_action) assert length(result.internal_queue) == 1 [event] = result.internal_queue @@ -415,7 +415,7 @@ defmodule Statifier.Actions.SendActionTest do } state_chart = create_test_state_chart() - result = SendAction.execute(send_action, state_chart) + result = SendAction.execute(state_chart, send_action) assert length(result.internal_queue) == 1 [event] = result.internal_queue @@ -437,7 +437,7 @@ defmodule Statifier.Actions.SendActionTest do } state_chart = create_test_state_chart() - result = SendAction.execute(send_action, state_chart) + result = SendAction.execute(state_chart, send_action) # Invalid expressions evaluate to :undefined, so it's included assert length(result.internal_queue) == 1 @@ -459,7 +459,7 @@ defmodule Statifier.Actions.SendActionTest do datamodel = %{"existingVar" => "exists"} state_chart = create_test_state_chart(datamodel) - result = SendAction.execute(send_action, state_chart) + result = SendAction.execute(state_chart, send_action) # Should skip bad location and include good location assert length(result.internal_queue) == 1 @@ -477,7 +477,7 @@ defmodule Statifier.Actions.SendActionTest do } state_chart = create_test_state_chart() - result = SendAction.execute(send_action, state_chart) + result = SendAction.execute(state_chart, send_action) # Should skip param with no value source assert length(result.internal_queue) == 1 @@ -496,7 +496,7 @@ defmodule Statifier.Actions.SendActionTest do } state_chart = create_test_state_chart() - result = SendAction.execute(send_action, state_chart) + result = SendAction.execute(state_chart, send_action) # Should handle compilation errors gracefully assert length(result.internal_queue) == 1 diff --git a/test/statifier/validator/expression_compiler_test.exs b/test/statifier/validator/expression_compiler_test.exs new file mode 100644 index 0000000..66692fa --- /dev/null +++ b/test/statifier/validator/expression_compiler_test.exs @@ -0,0 +1,348 @@ +defmodule Statifier.Validator.ExpressionCompilerTest do + use ExUnit.Case, async: true + + alias Statifier.{Document, State, Transition} + alias Statifier.Actions.{AssignAction, ForeachAction, IfAction, SendAction} + alias Statifier.Validator.ExpressionCompiler + + describe "compile_document/1" do + test "compiles expressions in transitions" do + transition = %Transition{ + cond: "x > 5", + compiled_cond: nil, + source_location: %{line: 1, column: 1} + } + + state = %State{ + id: "test_state", + transitions: [transition], + onentry_actions: [], + onexit_actions: [], + states: [] + } + + document = %Document{ + states: [state] + } + + {warnings, compiled_document} = ExpressionCompiler.compile_document(document) + + assert warnings == [] + [compiled_state] = compiled_document.states + [compiled_transition] = compiled_state.transitions + refute is_nil(compiled_transition.compiled_cond) + assert is_list(compiled_transition.compiled_cond) + end + + test "compiles expressions in AssignAction" do + assign_action = AssignAction.new("user.name", "'John'") + + state = %State{ + id: "test_state", + transitions: [], + onentry_actions: [assign_action], + onexit_actions: [], + states: [] + } + + document = %Document{ + states: [state] + } + + {warnings, compiled_document} = ExpressionCompiler.compile_document(document) + + assert warnings == [] + [compiled_state] = compiled_document.states + [compiled_action] = compiled_state.onentry_actions + refute is_nil(compiled_action.compiled_expr) + assert is_list(compiled_action.compiled_expr) + end + + test "compiles expressions in SendAction" do + send_action = %SendAction{ + event_expr: "'test.event'", + target_expr: "'#_internal'", + type_expr: nil, + delay_expr: "'1s'", + compiled_event_expr: nil, + compiled_target_expr: nil, + compiled_type_expr: nil, + compiled_delay_expr: nil, + params: [], + source_location: %{line: 1, column: 1} + } + + state = %State{ + id: "test_state", + transitions: [], + onentry_actions: [send_action], + onexit_actions: [], + states: [] + } + + document = %Document{ + states: [state] + } + + {warnings, compiled_document} = ExpressionCompiler.compile_document(document) + + assert warnings == [] + [compiled_state] = compiled_document.states + [compiled_action] = compiled_state.onentry_actions + refute is_nil(compiled_action.compiled_event_expr) + # This gets compiled too + refute is_nil(compiled_action.compiled_target_expr) + # This was nil in original + assert is_nil(compiled_action.compiled_type_expr) + refute is_nil(compiled_action.compiled_delay_expr) + end + + test "compiles expressions in IfAction" do + blocks = [ + %{type: :if, cond: "x > 5", actions: [], compiled_cond: nil}, + %{type: :elseif, cond: "x < 2", actions: [], compiled_cond: nil}, + %{type: :else, cond: nil, actions: [], compiled_cond: nil} + ] + + if_action = %IfAction{ + conditional_blocks: blocks, + source_location: %{line: 1, column: 1} + } + + state = %State{ + id: "test_state", + transitions: [], + onentry_actions: [if_action], + onexit_actions: [], + states: [] + } + + document = %Document{ + states: [state] + } + + {warnings, compiled_document} = ExpressionCompiler.compile_document(document) + + assert warnings == [] + [compiled_state] = compiled_document.states + [compiled_action] = compiled_state.onentry_actions + [if_block, elseif_block, else_block] = compiled_action.conditional_blocks + + refute is_nil(if_block[:compiled_cond]) + refute is_nil(elseif_block[:compiled_cond]) + # else blocks don't have conditions + assert is_nil(else_block[:compiled_cond]) + end + + test "compiles expressions in ForeachAction" do + foreach_action = %ForeachAction{ + array: "items", + item: "item", + index: "index", + actions: [], + compiled_array: nil, + source_location: %{line: 1, column: 1} + } + + state = %State{ + id: "test_state", + transitions: [], + onentry_actions: [foreach_action], + onexit_actions: [], + states: [] + } + + document = %Document{ + states: [state] + } + + {warnings, compiled_document} = ExpressionCompiler.compile_document(document) + + assert warnings == [] + [compiled_state] = compiled_document.states + [compiled_action] = compiled_state.onentry_actions + refute is_nil(compiled_action.compiled_array) + assert is_list(compiled_action.compiled_array) + end + + test "handles nested states recursively" do + child_transition = %Transition{ + cond: "nested_condition", + compiled_cond: nil, + source_location: %{line: 2, column: 1} + } + + child_state = %State{ + id: "child_state", + transitions: [child_transition], + onentry_actions: [], + onexit_actions: [], + states: [] + } + + parent_state = %State{ + id: "parent_state", + transitions: [], + onentry_actions: [], + onexit_actions: [], + states: [child_state] + } + + document = %Document{ + states: [parent_state] + } + + {warnings, compiled_document} = ExpressionCompiler.compile_document(document) + + assert warnings == [] + [compiled_parent] = compiled_document.states + [compiled_child] = compiled_parent.states + [compiled_transition] = compiled_child.transitions + refute is_nil(compiled_transition.compiled_cond) + end + + test "handles actions in onexit_actions" do + assign_action = AssignAction.new("result", "'exit_value'") + + state = %State{ + id: "test_state", + transitions: [], + onentry_actions: [], + onexit_actions: [assign_action], + states: [] + } + + document = %Document{ + states: [state] + } + + {warnings, compiled_document} = ExpressionCompiler.compile_document(document) + + assert warnings == [] + [compiled_state] = compiled_document.states + [compiled_action] = compiled_state.onexit_actions + refute is_nil(compiled_action.compiled_expr) + end + + test "returns warnings for invalid expressions" do + # Invalid transition condition + invalid_transition = %Transition{ + cond: "invalid.syntax..error", + compiled_cond: nil, + source_location: %{line: 1, column: 5} + } + + # Invalid assign expression + invalid_assign = AssignAction.new("location", "invalid.syntax..error") + + state = %State{ + id: "test_state", + transitions: [invalid_transition], + onentry_actions: [invalid_assign], + onexit_actions: [], + states: [] + } + + document = %Document{ + states: [state] + } + + {warnings, compiled_document} = ExpressionCompiler.compile_document(document) + + # Should have warnings for both invalid expressions + assert length(warnings) >= 2 + assert Enum.any?(warnings, &String.contains?(&1, "transition condition")) + assert Enum.any?(warnings, &String.contains?(&1, "assign action expression")) + assert Enum.any?(warnings, &String.contains?(&1, "line 1")) + + # Compiled expressions should be nil for invalid expressions + [compiled_state] = compiled_document.states + [compiled_transition] = compiled_state.transitions + [compiled_action] = compiled_state.onentry_actions + assert is_nil(compiled_transition.compiled_cond) + assert is_nil(compiled_action.compiled_expr) + end + + test "handles empty document" do + document = %Document{states: []} + + {warnings, compiled_document} = ExpressionCompiler.compile_document(document) + + assert warnings == [] + assert compiled_document.states == [] + end + + test "handles unknown action types" do + # Create a mock action that's not one of the known types + unknown_action = %{type: :unknown, data: "some data"} + + state = %State{ + id: "test_state", + transitions: [], + onentry_actions: [unknown_action], + onexit_actions: [], + states: [] + } + + document = %Document{ + states: [state] + } + + {warnings, compiled_document} = ExpressionCompiler.compile_document(document) + + assert warnings == [] + [compiled_state] = compiled_document.states + [compiled_action] = compiled_state.onentry_actions + # Unknown action should be passed through unchanged + assert compiled_action == unknown_action + end + + test "handles nil expressions gracefully" do + # Test with nil expressions in various places + transition = %Transition{ + cond: nil, + compiled_cond: nil, + source_location: nil + } + + send_action = %SendAction{ + event_expr: nil, + target_expr: nil, + type_expr: nil, + delay_expr: nil, + compiled_event_expr: nil, + compiled_target_expr: nil, + compiled_type_expr: nil, + compiled_delay_expr: nil, + params: [], + source_location: nil + } + + state = %State{ + id: "test_state", + transitions: [transition], + onentry_actions: [send_action], + onexit_actions: [], + states: [] + } + + document = %Document{ + states: [state] + } + + {warnings, compiled_document} = ExpressionCompiler.compile_document(document) + + assert warnings == [] + [compiled_state] = compiled_document.states + [compiled_transition] = compiled_state.transitions + [compiled_action] = compiled_state.onentry_actions + + # All nil expressions should remain nil + assert is_nil(compiled_transition.compiled_cond) + assert is_nil(compiled_action.compiled_event_expr) + assert is_nil(compiled_action.compiled_target_expr) + assert is_nil(compiled_action.compiled_type_expr) + assert is_nil(compiled_action.compiled_delay_expr) + end + end +end