diff --git a/lib/sc/document/validator.ex b/lib/sc/document/validator.ex index 220c028..fd3986d 100644 --- a/lib/sc/document/validator.ex +++ b/lib/sc/document/validator.ex @@ -57,7 +57,9 @@ defmodule SC.Document.Validator do case validated_result.errors do [] -> # Only optimize valid documents - SC.Document.build_lookup_maps(document) + document + |> determine_state_types() + |> SC.Document.build_lookup_maps() _errors -> # Don't waste time optimizing invalid documents @@ -282,4 +284,39 @@ defmodule SC.Document.Validator do add_warning(result, "Document initial state '#{initial_id}' is not a top-level state") end end + + # State type determination (moved from parser) + + # Determine state types for all states in the document based on structure + @spec determine_state_types(SC.Document.t()) :: SC.Document.t() + defp determine_state_types(%SC.Document{} = document) do + updated_states = Enum.map(document.states, &update_state_types/1) + %{document | states: updated_states} + end + + # Update state types based on structure after parsing is complete + @spec update_state_types(SC.State.t()) :: SC.State.t() + defp update_state_types(%SC.State{type: :parallel} = state) do + # Parallel states keep their type, just update children + updated_children = Enum.map(state.states, &update_state_types/1) + %{state | states: updated_children} + end + + defp update_state_types(%SC.State{} = state) do + # Update children first (bottom-up) + updated_children = Enum.map(state.states, &update_state_types/1) + + # Determine this state's type based on structure (atomic vs compound) + state_type = determine_state_type(updated_children, state.initial) + + %{state | type: state_type, states: updated_children} + end + + defp determine_state_type(child_states, initial_value) do + cond do + Enum.empty?(child_states) -> :atomic + initial_value != nil or not Enum.empty?(child_states) -> :compound + true -> :atomic + end + end end diff --git a/lib/sc/interpreter.ex b/lib/sc/interpreter.ex index c2ad653..89d536b 100644 --- a/lib/sc/interpreter.ex +++ b/lib/sc/interpreter.ex @@ -51,10 +51,10 @@ defmodule SC.Interpreter do # No matching transitions - return unchanged (silent handling) {:ok, state_chart} - [transition | _rest] -> - # Execute the first enabled transition + transitions -> + # Execute all enabled transitions (for parallel regions) new_config = - execute_transition(state_chart.configuration, transition, state_chart.document) + execute_transitions(state_chart.configuration, transitions, state_chart.document) {:ok, StateChart.update_configuration(state_chart, new_config)} end @@ -93,7 +93,7 @@ defmodule SC.Interpreter do %Document{initial: nil, states: [first_state | _rest]} = document ) do # No initial specified - use first state and enter it properly - initial_states = enter_compound_state(first_state, document) + initial_states = enter_state(first_state, document) Configuration.new(initial_states) end @@ -104,29 +104,38 @@ defmodule SC.Interpreter do %Configuration{} state -> - initial_states = enter_compound_state(state, document) + initial_states = enter_state(state, document) Configuration.new(initial_states) end end - # Enter a compound state by recursively entering its initial child states. + # Enter a state by recursively entering its initial child states based on type. # Returns a list of leaf state IDs that should be active. - defp enter_compound_state(%SC.State{states: []} = state, _document) do + defp enter_state(%SC.State{type: :atomic} = state, _document) do # Atomic state - return its ID [state.id] end - defp enter_compound_state(%SC.State{states: child_states, initial: initial_id}, document) do + defp enter_state( + %SC.State{type: :compound, states: child_states, initial: initial_id}, + document + ) do # Compound state - find and enter initial child (don't add compound state to active set) initial_child = get_initial_child_state(initial_id, child_states) case initial_child do # No valid child - compound state with no children is not active nil -> [] - child -> enter_compound_state(child, document) + child -> enter_state(child, document) end end + defp enter_state(%SC.State{type: :parallel, states: child_states}, document) do + # Parallel state - enter ALL children simultaneously + child_states + |> Enum.flat_map(&enter_state(&1, document)) + end + # Get the initial child state for a compound state defp get_initial_child_state(nil, [first_child | _rest]), do: first_child @@ -136,8 +145,6 @@ defmodule SC.Interpreter do defp get_initial_child_state(_initial_id, []), do: nil - # Find a state by ID in the document (using the more efficient implementation below) - defp find_enabled_transitions(%StateChart{} = state_chart, %Event{} = event) do # Get all currently active leaf states active_leaf_states = Configuration.active_states(state_chart.configuration) @@ -155,35 +162,52 @@ defmodule SC.Interpreter do |> Enum.sort_by(& &1.document_order) end - defp execute_transition( + # Execute transitions with proper SCXML semantics + defp execute_transitions( %Configuration{} = config, - %SC.Transition{} = transition, + transitions, %Document{} = document ) do + # Group transitions by source state to handle document order correctly + transitions_by_source = Enum.group_by(transitions, & &1.source) + + # For each source state, take only the first transition (document order) + # This handles both regular states and parallel regions correctly + selected_transitions = + transitions_by_source + |> Enum.flat_map(fn {_source_id, source_transitions} -> + # Take first transition in document order (transitions are already sorted) + case source_transitions do + [] -> [] + # Only first transition per source state + [first | _rest] -> [first] + end + end) + + # Execute the selected transitions + target_leaf_states = + selected_transitions + |> Enum.flat_map(&execute_single_transition(&1, document)) + + case target_leaf_states do + # No valid transitions + [] -> config + states -> Configuration.new(states) + end + end + + # Execute a single transition and return target leaf states + defp execute_single_transition(transition, document) do case transition.target do - # No target - stay in same state + # No target nil -> - config + [] target_id -> - # Proper compound state transition: - # 1. Find target state in document using O(1) lookup - # 2. If compound, enter its initial children - # 3. Return new configuration with leaf states only case Document.find_state(document, target_id) do - nil -> - # Invalid target - stay in current state - config - - target_state -> - # For now: replace all active states with target and its children - # Future: Implement proper SCXML exit/entry sequence with LCA computation - target_leaf_states = enter_compound_state(target_state, document) - Configuration.new(target_leaf_states) + nil -> [] + target_state -> enter_state(target_state, document) end end end - - # These functions are no longer needed - we use Document.find_state/2 - # and Document.get_transitions_from_state/2 for O(1) lookups end diff --git a/lib/sc/parser/scxml/element_builder.ex b/lib/sc/parser/scxml/element_builder.ex index 699cbb3..442b2b3 100644 --- a/lib/sc/parser/scxml/element_builder.ex +++ b/lib/sc/parser/scxml/element_builder.ex @@ -63,6 +63,8 @@ defmodule SC.Parser.SCXML.ElementBuilder do %SC.State{ id: get_attr_value(attrs_map, "id"), initial: get_attr_value(attrs_map, "initial"), + # Will be updated later based on children and structure + type: :atomic, states: [], transitions: [], document_order: document_order, @@ -73,6 +75,34 @@ defmodule SC.Parser.SCXML.ElementBuilder do } end + @doc """ + Build an SC.State from parallel XML attributes and location info. + """ + @spec build_parallel_state(list(), map(), String.t(), map()) :: SC.State.t() + def build_parallel_state(attributes, location, xml_string, element_counts) do + attrs_map = attributes_to_map(attributes) + document_order = LocationTracker.document_order(element_counts) + + # Calculate attribute-specific locations + id_location = LocationTracker.attribute_location(xml_string, "id", location) + + %SC.State{ + id: get_attr_value(attrs_map, "id"), + # Parallel states don't have initial attributes + initial: nil, + # Set type directly during parsing + type: :parallel, + states: [], + transitions: [], + document_order: document_order, + # Location information + source_location: location, + id_location: id_location, + # Parallel states don't have initial + initial_location: nil + } + end + @doc """ Build an SC.Transition from XML attributes and location info. """ diff --git a/lib/sc/parser/scxml/handler.ex b/lib/sc/parser/scxml/handler.ex index 84bad0a..e39ccc6 100644 --- a/lib/sc/parser/scxml/handler.ex +++ b/lib/sc/parser/scxml/handler.ex @@ -53,6 +53,9 @@ defmodule SC.Parser.SCXML.Handler do "state" -> handle_state_start(attributes, location, state) + "parallel" -> + handle_parallel_start(attributes, location, state) + "transition" -> handle_transition_start(attributes, location, state) @@ -77,6 +80,10 @@ defmodule SC.Parser.SCXML.Handler do "state" -> StateStack.handle_state_end(state) + "parallel" -> + # Handle parallel same as state + StateStack.handle_state_end(state) + "transition" -> StateStack.handle_transition_end(state) @@ -125,6 +132,23 @@ defmodule SC.Parser.SCXML.Handler do {:ok, StateStack.push_element(updated_state, "state", state_element)} end + defp handle_parallel_start(attributes, location, state) do + parallel_element = + ElementBuilder.build_parallel_state( + attributes, + location, + state.xml_string, + state.element_counts + ) + + updated_state = %{ + state + | current_element: {:parallel, parallel_element} + } + + {:ok, StateStack.push_element(updated_state, "parallel", parallel_element)} + end + defp handle_transition_start(attributes, location, state) do transition = ElementBuilder.build_transition( diff --git a/lib/sc/parser/scxml/state_stack.ex b/lib/sc/parser/scxml/state_stack.ex index bc6728c..53aac03 100644 --- a/lib/sc/parser/scxml/state_stack.ex +++ b/lib/sc/parser/scxml/state_stack.ex @@ -29,7 +29,7 @@ defmodule SC.Parser.SCXML.StateStack do {:ok, updated_state} [{"state", parent_state} | rest] -> - # State is nested - calculate depth from stack level + # State is nested in another state - calculate depth from stack level # Stack depth = document level (0) + state nesting level current_depth = calculate_stack_depth(rest) + 1 @@ -42,6 +42,19 @@ defmodule SC.Parser.SCXML.StateStack do updated_parent = %{parent_state | states: parent_state.states ++ [state_with_hierarchy]} {:ok, %{state | stack: [{"state", updated_parent} | rest]}} + [{"parallel", parent_state} | rest] -> + # State is nested in a parallel state - calculate depth from stack level + current_depth = calculate_stack_depth(rest) + 1 + + state_with_hierarchy = %{ + state_element + | parent: parent_state.id, + depth: current_depth + } + + updated_parent = %{parent_state | states: parent_state.states ++ [state_with_hierarchy]} + {:ok, %{state | stack: [{"parallel", updated_parent} | rest]}} + _other_parent -> {:ok, %{state | stack: parent_stack}} end @@ -57,9 +70,27 @@ defmodule SC.Parser.SCXML.StateStack do case parent_stack do [{"state", parent_state} | rest] -> - updated_parent = %{parent_state | transitions: parent_state.transitions ++ [transition]} + # Set the source state ID on the transition + transition_with_source = %{transition | source: parent_state.id} + + updated_parent = %{ + parent_state + | transitions: parent_state.transitions ++ [transition_with_source] + } + {:ok, %{state | stack: [{"state", updated_parent} | rest]}} + [{"parallel", parent_state} | rest] -> + # Set the source state ID for parallel states too + transition_with_source = %{transition | source: parent_state.id} + + updated_parent = %{ + parent_state + | transitions: parent_state.transitions ++ [transition_with_source] + } + + {:ok, %{state | stack: [{"parallel", updated_parent} | rest]}} + _other_parent -> {:ok, %{state | stack: parent_stack}} end diff --git a/lib/sc/state.ex b/lib/sc/state.ex index 3c531f3..185de97 100644 --- a/lib/sc/state.ex +++ b/lib/sc/state.ex @@ -6,6 +6,7 @@ defmodule SC.State do defstruct [ :id, :initial, + type: :atomic, states: [], transitions: [], # Hierarchy navigation @@ -19,9 +20,12 @@ defmodule SC.State do initial_location: nil ] + @type state_type :: :atomic | :compound | :parallel | :history | :initial | :final + @type t :: %__MODULE__{ id: String.t(), initial: String.t() | nil, + type: state_type(), states: [SC.State.t()], transitions: [SC.Transition.t()], parent: String.t() | nil, diff --git a/lib/sc/transition.ex b/lib/sc/transition.ex index 9df0626..fd75b21 100644 --- a/lib/sc/transition.ex +++ b/lib/sc/transition.ex @@ -7,6 +7,8 @@ defmodule SC.Transition do :event, :target, :cond, + # Source state ID - set during parsing + source: nil, # Document order for deterministic processing document_order: nil, # Location information for validation @@ -20,6 +22,7 @@ defmodule SC.Transition do event: String.t() | nil, target: String.t() | nil, cond: String.t() | nil, + source: String.t() | nil, document_order: integer() | nil, source_location: map() | nil, event_location: map() | nil, diff --git a/test/sc/document/validator_state_types_test.exs b/test/sc/document/validator_state_types_test.exs new file mode 100644 index 0000000..8fe55ec --- /dev/null +++ b/test/sc/document/validator_state_types_test.exs @@ -0,0 +1,86 @@ +defmodule SC.Document.ValidatorStateTypesTest do + use ExUnit.Case, async: true + + alias SC.{Document, Parser.SCXML} + + describe "state type determination in validator" do + test "determines atomic state type" do + xml = """ + + + + + """ + + {:ok, document} = SCXML.parse(xml) + {:ok, optimized_document, _warnings} = Document.Validator.validate(document) + + [state] = optimized_document.states + assert state.type == :atomic + end + + test "determines compound state type" do + xml = """ + + + + + + + """ + + {:ok, document} = SCXML.parse(xml) + {:ok, optimized_document, _warnings} = Document.Validator.validate(document) + + [parent_state] = optimized_document.states + assert parent_state.type == :compound + + [child_state] = parent_state.states + assert child_state.type == :atomic + end + + test "state types are only determined for valid documents" do + xml = """ + + + + + """ + + {:ok, document} = SCXML.parse(xml) + {:error, _errors, _warnings} = Document.Validator.validate(document) + + # Invalid documents should not have state types determined or lookup maps built + [state] = document.states + # Still the default from parsing + assert state.type == :atomic + # No lookup maps built + assert document.state_lookup == %{} + end + + test "nested compound states have correct types" do + xml = """ + + + + + + + + + """ + + {:ok, document} = SCXML.parse(xml) + {:ok, optimized_document, _warnings} = Document.Validator.validate(document) + + [level1] = optimized_document.states + assert level1.type == :compound + + [level2] = level1.states + assert level2.type == :compound + + [leaf] = level2.states + assert leaf.type == :atomic + end + end +end diff --git a/test/sc/parser/parallel_parsing_test.exs b/test/sc/parser/parallel_parsing_test.exs new file mode 100644 index 0000000..92291d4 --- /dev/null +++ b/test/sc/parser/parallel_parsing_test.exs @@ -0,0 +1,84 @@ +defmodule SC.Parser.ParallelParsingTest do + use ExUnit.Case, async: true + + alias SC.{Document, Parser.SCXML} + + describe "parallel state parsing" do + test "parses simple parallel state" do + xml = """ + + + + + + + + """ + + assert {:ok, + %Document{ + states: [ + %SC.State{ + id: "p", + # Set directly during parsing + type: :parallel, + states: [ + %SC.State{id: "a", type: :atomic}, + %SC.State{id: "b", type: :atomic} + ] + } + ] + }} = SCXML.parse(xml) + end + + test "parallel state type is determined correctly during validation" do + xml = """ + + + + + + + + """ + + {:ok, document} = SCXML.parse(xml) + {:ok, validated_document, _warnings} = Document.Validator.validate(document) + + [parallel_state] = validated_document.states + assert parallel_state.type == :parallel + + # Child states should remain atomic + [child_a, child_b] = parallel_state.states + assert child_a.type == :atomic + assert child_b.type == :atomic + end + + test "nested parallel and compound states" do + xml = """ + + + + + + + + + + + """ + + {:ok, document} = SCXML.parse(xml) + {:ok, validated_document, _warnings} = Document.Validator.validate(document) + + [parallel_state] = validated_document.states + assert parallel_state.type == :parallel + + [region1, region2] = parallel_state.states + # Has children with initial + assert region1.type == :compound + # No children + assert region2.type == :atomic + end + end +end diff --git a/test/sc/parser/transition_source_test.exs b/test/sc/parser/transition_source_test.exs new file mode 100644 index 0000000..275c322 --- /dev/null +++ b/test/sc/parser/transition_source_test.exs @@ -0,0 +1,102 @@ +defmodule SC.Parser.TransitionSourceTest do + use ExUnit.Case, async: true + + alias SC.{Document, Parser.SCXML} + + describe "transition source field" do + test "sets source field during parsing for regular states" do + xml = """ + + + + + + + + """ + + assert {:ok, + %Document{ + states: [ + %SC.State{ + id: "start", + transitions: [ + %SC.Transition{ + event: "go", + target: "end", + # Source should be set during parsing + source: "start" + } + ] + }, + %SC.State{id: "end"} + ] + }} = SCXML.parse(xml) + end + + test "sets source field for transitions in parallel states" do + xml = """ + + + + + + + + + + + + + + """ + + {:ok, document} = SCXML.parse(xml) + + [parallel_state | _other_states] = document.states + [region1, region2] = parallel_state.states + + # Check that transitions have correct source + [transition1] = region1.transitions + assert transition1.source == "region1" + assert transition1.event == "t" + assert transition1.target == "region1_next" + + [transition2] = region2.transitions + assert transition2.source == "region2" + assert transition2.event == "t" + assert transition2.target == "region2_next" + end + + test "sets source field for nested compound states" do + xml = """ + + + + + + + + + + + + """ + + {:ok, document} = SCXML.parse(xml) + + [parent_state, _end_state] = document.states + + # Parent transition should have parent as source + [parent_transition] = parent_state.transitions + assert parent_transition.source == "parent" + assert parent_transition.event == "exit" + + # Child transition should have child as source + [child1, _child2] = parent_state.states + [child_transition] = child1.transitions + assert child_transition.source == "child1" + assert child_transition.event == "next" + end + end +end