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