packages feed

scxml-statecharts-0.1.0.0: src/Scxml/Statechart/Interpret.hs

-- | The evaluator. One recursive pass over the chart tree per event.
--
-- Because a transition may only target a sibling of its source, a transition
-- never moves anything outside its parent. That collapses most of the general
-- SCXML machinery: there are no least common ancestors to find, no exit sets
-- to filter out of the whole configuration, and no conflicts to resolve
-- between transitions at different depths. A state only ever rearranges its
-- own children, so the whole algorithm is a walk down and back up, and nothing
-- needs looking up by id.
--
-- A configuration is the set of all active states, ancestors included, as in
-- SCXML. The typed state generated for a chart is an isomorphic view of a
-- legal one.
module Scxml.Statechart.Interpret
  ( Configuration
  , Phase (..)
  , Callbacks (..)
  , doneEventName
  , start
  , macrostep
  ) where

import Data.List.NonEmpty (NonEmpty ((:|)))
import qualified Data.List.NonEmpty as NE
import qualified Data.Map.Strict as Map
import Data.Maybe (listToMaybe)
import Data.Set (Set)
import qualified Data.Set as Set
import Data.Text (Text)
import qualified Data.Text as T

import Scxml.Statechart.Model

type Configuration = Set StateId

-- | Where a callback is attached. Exit callbacks may not raise events.
data Phase = OnEntry | OnExit
  deriving (Eq, Ord, Show)

-- | How the evaluator reaches the callbacks, in the only terms it knows:
-- state ids and event names. "Scxml.Statechart.Run" wraps the typed
-- 'Scxml.Statechart.Run.Hooks' into one of these.
newtype Callbacks m = Callbacks
  { runCallback :: Phase -> Text -> Configuration -> Maybe Text -> m (Maybe Text)
    -- ^ run the named callback, given the configuration it observes and the
    -- event being processed ('Nothing' during 'start'); returns an event to raise
  }

-- | The event SCXML raises when a state completes.
doneEventName :: StateId -> Text
doneEventName s = T.pack "done.state." <> s

-- Entering ------------------------------------------------------------------

-- | The result of entering a state: its subtree's configuration, the states
-- entered with the done events each entry implies, and whether the subtree is
-- now in a final state.
data Entered = Entered
  { enConfig  :: Configuration
  , enEntered :: [(Node, [Text])] -- ^ outermost first
  , enFinal   :: Bool
  }

-- | Entering a final child is what completes its parent, so the parent's done
-- event is raised just after that child's own entry callbacks.
completing :: StateId -> Node -> Entered -> Entered
completing parent target e
  | nodeKind target /= Final = e
  | otherwise = e {enEntered = attach (enEntered e), enFinal = True}
  where
    attach ((h, ds) : rest) = (h, ds ++ [doneEventName parent]) : rest
    attach [] = []

-- | Enter a state and everything default entry into it implies.
enter :: Node -> Entered
enter n = case nodeKind n of
  Atomic -> leaf False
  Final -> leaf True
  Compound (c :| _) ->
    let below = completing (nodeId n) c (enter c)
     in Entered
          { enConfig = Set.insert (nodeId n) (enConfig below)
          , enEntered = (n, []) : enEntered below
          , enFinal = enFinal below
          }
  Parallel rs ->
    let belows = fmap enter rs
        allFinal = all enFinal belows
        dones = [doneEventName (nodeId n) | allFinal]
     in Entered
          { enConfig = Set.insert (nodeId n) (Set.unions (fmap enConfig (NE.toList belows)))
          , enEntered = (n, dones) : concatMap enEntered (NE.toList belows)
          , enFinal = allFinal
          }
  where
    leaf isFin =
      Entered {enConfig = Set.singleton (nodeId n), enEntered = [(n, [])], enFinal = isFin}

-- | The active states of a subtree, innermost first, which is the order exit
-- callbacks run in.
exiting :: Configuration -> Node -> [Node]
exiting cfg n = concatMap (exiting cfg) (activeChildren cfg n) ++ [n]

-- | The children of a state that are currently active: one for a compound
-- state, all of them for a parallel state, none for a leaf.
activeChildren :: Configuration -> Node -> [Node]
activeChildren cfg n = case nodeKind n of
  Compound kids -> maybe [] pure (activeChild cfg kids)
  Parallel rs -> NE.toList rs
  _ -> []

activeChild :: Configuration -> NonEmpty Node -> Maybe Node
activeChild cfg kids = listToMaybe (NE.filter (\c -> Set.member (nodeId c) cfg) kids)

-- | A named child. The parser has already checked that every transition
-- target is one of its source's siblings, so this cannot fail for a chart the
-- quasiquoter built.
childNamed :: StateId -> NonEmpty Node -> Node
childNamed tgt kids = case NE.filter ((== tgt) . nodeId) kids of
  t : _ -> t
  [] -> error ("Statechart: unknown transition target " ++ T.unpack tgt)

-- Offering an event ---------------------------------------------------------

-- | What a subtree reports after being offered an event.
data Reply = Reply
  { rpMove :: Maybe StateId
    -- ^ 'Just' when this state itself has a transition for the event. Its
    -- parent performs the switch, since the target is one of the parent's
    -- children.
  , rpConfig :: Configuration -- ^ meaningful only when 'rpMove' is 'Nothing'
  , rpExited :: [Node] -- ^ innermost first
  , rpEntered :: [(Node, [Text])] -- ^ outermost first
  , rpConsumed :: Bool
  , rpFinal :: Bool
  }

-- | Offer an event to a subtree. Children are asked first, and a state only
-- acts on the event if nothing below it did, so the innermost transition wins
-- and a transition on an enclosing state behaves as a default.
offer :: Configuration -> Text -> Node -> Reply
offer cfg ev n = case nodeKind n of
  Atomic -> own
  Final -> own
  Compound kids -> case activeChild cfg kids of
    Nothing -> own
    Just active ->
      let below = offer cfg ev active
       in if rpConsumed below then absorb kids active below else own
  Parallel rs ->
    let belows = fmap (offer cfg ev) rs
     in if any rpConsumed belows then absorbRegions belows else own
  where
    -- This state's own transition, for its parent to perform.
    own = case Map.lookup ev (nodeTransitions n) of
      Just tgt -> stay {rpMove = Just tgt, rpConsumed = True}
      Nothing -> stay
    stay =
      Reply
        { rpMove = Nothing
        , rpConfig = subtree cfg n
        , rpExited = []
        , rpEntered = []
        , rpConsumed = False
        , rpFinal = inFinalState cfg n
        }

    -- A compound state whose active child either moved to a sibling or
    -- settled internally. Either way this state stays put.
    absorb kids active below = case rpMove below of
      Nothing ->
        stay
          { rpConfig = Set.insert (nodeId n) (rpConfig below)
          , rpExited = rpExited below
          , rpEntered = rpEntered below
          , rpConsumed = True
          , rpFinal = nodeKind active == Final
          }
      Just tgt ->
        let target = childNamed tgt kids
            entered = completing (nodeId n) target (enter target)
         in stay
              { rpConfig = Set.insert (nodeId n) (enConfig entered)
              , rpExited = exiting cfg active
              , rpEntered = enEntered entered
              , rpConsumed = True
              , rpFinal = enFinal entered
              }

    -- A parallel state: regions cannot have transitions, so none of them can
    -- move, and their subtrees merge unchanged apart from what settled inside.
    absorbRegions belows =
      let allFinal = all rpFinal belows
          justCompleted = allFinal && not (inFinalState cfg n)
       in stay
            { rpConfig = Set.insert (nodeId n) (Set.unions (fmap rpConfig (NE.toList belows)))
            , rpExited = concatMap rpExited (NE.toList belows)
            , rpEntered =
                concatMap rpEntered (NE.toList belows)
                  ++ [(n, [doneEventName (nodeId n)]) | justCompleted]
            , rpConsumed = True
            , rpFinal = allFinal
            }


-- | The active states of a subtree, as a set.
subtree :: Configuration -> Node -> Configuration
subtree cfg n = Set.fromList (map nodeId (exiting cfg n))

-- | Whether a state counts as completed: a final state is, a compound state is
-- when its active child is final, and a parallel state is when every region
-- is. Only a parallel state's own completion consults this.
inFinalState :: Configuration -> Node -> Bool
inFinalState cfg n = case nodeKind n of
  Final -> True
  Atomic -> False
  Compound kids -> maybe False ((== Final) . nodeKind) (activeChild cfg kids)
  Parallel rs -> all (inFinalState cfg) (NE.toList rs)

-- Running -------------------------------------------------------------------

-- | Enter the chart's initial state, then process whatever that raises.
start :: Monad m => Chart -> Callbacks m -> m Configuration
start ch cbs = do
  let entered = enter (NE.head (chartRoot ch))
      cfg = enConfig entered
  raised <- runEntries cbs cfg Nothing (enEntered entered)
  runToCompletion ch cbs cfg raised

-- | Process one external event. 'Nothing' if no transition was enabled for it.
macrostep :: Monad m => Chart -> Callbacks m -> Configuration -> Text -> m (Maybe Configuration)
macrostep ch cbs cfg ev = do
  r <- microstep ch cbs cfg ev
  case r of
    Nothing -> pure Nothing
    Just (cfg', raised) -> Just <$> runToCompletion ch cbs cfg' raised

-- | Process raised events in order until the queue is empty. One that no
-- transition handles is dropped.
runToCompletion :: Monad m => Chart -> Callbacks m -> Configuration -> [Text] -> m Configuration
runToCompletion ch cbs = go (0 :: Int)
  where
    go _ cfg [] = pure cfg
    go n cfg (e : rest)
      | n > 1000 = error "Statechart: raised events do not terminate (a callback keeps raising an event that leads back to it)"
      | otherwise = do
          r <- microstep ch cbs cfg e
          case r of
            Nothing -> go (n + 1) cfg rest
            Just (cfg', raised) -> go (n + 1) cfg' (rest ++ raised)

-- | One event, one pass. The chart root behaves as a compound state: exactly
-- one of its children is active, and it has no transitions of its own.
microstep :: Monad m => Chart -> Callbacks m -> Configuration -> Text -> m (Maybe (Configuration, [Text]))
microstep ch cbs cfg ev =
  case activeChild cfg (chartRoot ch) of
    Nothing -> pure Nothing
    Just active ->
      let below = offer cfg ev active
       in if not (rpConsumed below)
            then pure Nothing
            else do
              let (cfg', exited, entered) = case rpMove below of
                    Nothing -> (rpConfig below, rpExited below, rpEntered below)
                    Just tgt ->
                      let e = enter (childNamed tgt (chartRoot ch))
                       in (enConfig e, exiting cfg active, enEntered e)
              mapM_ (runExits cbs cfg ev) exited
              raised <- runEntries cbs cfg' (Just ev) entered
              pure (Just (cfg', raised))

-- | Exit callbacks see the state being left, so they get the old configuration.
runExits :: Monad m => Callbacks m -> Configuration -> Text -> Node -> m ()
runExits cbs cfg ev n =
  mapM_ (\a -> runCallback cbs OnExit a cfg (Just ev)) (nodeOnExit n)

-- | Entry callbacks see the configuration the step settles in, so they all get
-- the new one even though they run outermost first.
runEntries :: Monad m => Callbacks m -> Configuration -> Maybe Text -> [(Node, [Text])] -> m [Text]
runEntries cbs cfg ev = fmap concat . mapM one
  where
    one (n, dones) = do
      raised <- mapM (\a -> runCallback cbs OnEntry a cfg ev) (nodeOnEntry n)
      pure ([r | Just r <- raised] ++ dones)