packages feed

scxml-statecharts-0.2.0.0: src/Scxml/Statechart/Parse.hs

-- | Parse SCXML into the untyped chart model.
--
-- Supported: @<state>@, @<parallel>@, @<final>@, @<transition>@ (event,
-- target), @initial@ attributes and @<initial>@ elements, and
-- @<script>@ inside @<onentry>@ and @<onexit>@ whose content is the name of a
-- Haskell function to run.
--
-- Deliberately unsupported: @cond@ guards, eventless transitions and
-- transitions without a target (make the decision in an @<onentry>@ callback
-- that raises an event instead), @<script>@ on a transition (put it in the
-- @<onentry>@ of the target, which receives the triggering event), and
-- @type="internal"@ (it can only differ from an external transition for a
-- target inside the source, which the level rule below forbids).
--
-- A transition must target a sibling of its source: events never cross
-- levels. To leave an enclosing state, put the transition on that state.
-- @initial@ follows the same rule and is required on every compound state:
-- it must name a direct child.
--
-- Not yet supported: @<history>@, wildcard event descriptors, other
-- executable content (@<assign>@, @<raise>@, @<send>@, ...).
--
-- State ids and event names are used verbatim as Haskell constructor and type
-- names, so they must be valid ones (@PaymentAuthorized@, not
-- @payment.authorized@). The one exception is SCXML's automatic
-- @done.state.X@ event, which becomes the constructor @DoneX@. The @name@
-- attribute on @<scxml>@ is optional metadata, kept in 'chartName' for
-- logging and persistence; it does not affect the generated names.
--
-- The @event@ attribute names one event and then the Haskell types its
-- constructor carries: @event="Ok Int"@ declares @Ok Int@. SCXML instead reads
-- the attribute as a space-separated list of event descriptors, which is the
-- one place this parser knowingly differs from the spec; two events reaching
-- one target are written as two @<transition>@ elements.
module Scxml.Statechart.Parse (parseScxml) where

import Control.Monad (ap, forM_, unless, when)
import Data.Char (isAlphaNum, isUpper)
import Data.List (group, intercalate, sort, stripPrefix)
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.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Lazy as TL
import Text.XML (Element, Name (nameLocalName))
import qualified Text.XML as X

import Scxml.Statechart.Model

-- A state+error monad collecting events in the order they are first seen,
-- each with the state that declared it so a later disagreement about its
-- payload can point back. Document order is derived from the tree, so nothing
-- counts here.
newtype P a = P {runP :: [(Event, String)] -> Either String (a, [(Event, String)])}

instance Functor P where
  fmap f (P g) = P $ \st -> fmap (\(a, st') -> (f a, st')) (g st)

instance Applicative P where
  pure a = P $ \st -> Right (a, st)
  (<*>) = ap

instance Monad P where
  P g >>= k = P $ \st -> g st >>= \(a, st') -> runP (k a) st'

throwP :: String -> P a
throwP msg = P $ \_ -> Left msg

liftE :: Either String a -> P a
liftE = either throwP pure

-- | Record an event at its first occurrence in the document. Every transition
-- naming an event reaches the same generated constructor, so a second one has
-- to agree about the payload.
seeEvent :: String -> Event -> P ()
seeEvent label e = P $ \seen ->
  case [d | d <- seen, eventName (fst d) == eventName e] of
    [] -> Right ((), seen ++ [(e, label)])
    (prev, declaredOn) : _
      | eventFields prev == eventFields e -> Right ((), seen)
      | otherwise ->
          Left $
            label ++ ": the event " ++ show (T.unpack (eventName e)) ++ " carries "
              ++ describeEvent e ++ " here and " ++ describeEvent prev ++ " on " ++ declaredOn
              ++ ". Every transition naming an event reaches the same constructor, so they must agree"

-- | An event's payload, for error messages.
describeEvent :: Event -> String
describeEvent e
  | null (eventFields e) = "nothing"
  | otherwise = unwords (map T.unpack (eventFields e))

-- | Parse and validate an SCXML document. The 'Left' case is a message meant
-- to be shown to whoever wrote the XML; the quasiquoter reports it as a
-- compile error.
parseScxml :: String -> Either String Chart
parseScxml src = do
  root <- parseXml src
  unless (localName root == "scxml") $
    Left ("root element must be <scxml>, found <" ++ localName root ++ ">")
  (kids, declared) <- runP (mapM buildNode (stateChildren root)) []
  rootKids <- case NE.nonEmpty kids of
    Just ks -> Right ks
    Nothing -> Left "<scxml> contains no states"
  initial <- initialOf "<scxml>" root (map nodeId kids)
  ordered <- initialFirst "<scxml>" initial rootKids
  checkUniqueIds (concatMap flatten kids)
  let ch =
        Chart
          { chartName = T.pack <$> attr "name" root
          , chartRoot = ordered
          , chartEvents = map fst declared
          }
  validate ch
  pure ch
  where
    flatten n = n : concatMap flatten (nodeChildren n)

-- | Put the initial child first, which is how the tree records it.
initialFirst :: String -> StateId -> NonEmpty Node -> Either String (NonEmpty Node)
initialFirst label initial kids =
  case NE.partition ((== initial) . nodeId) kids of
    ([i], rest) -> Right (i :| rest)
    _ -> Left (label ++ ": internal error, initial state " ++ T.unpack initial ++ " is not a unique child")

-- | Ids become Haskell constructors, so they must be unique chart-wide, which
-- is also what SCXML requires of them.
checkUniqueIds :: [Node] -> Either String ()
checkUniqueIds nodes = case dups of
  [] -> Right ()
  _ ->
    Left $
      "duplicate state ids: " ++ intercalate "; " (map describe dups)
        ++ ". State ids must be unique across the whole chart, whatever their parents:"
        ++ " SCXML ids are XML IDs, and each one becomes a Haskell constructor"
  where
    dups = [d | (d : _ : _) <- group (sort (map nodeId nodes))]
    parentOfId d = [nodeId p | p <- nodes, d `elem` map nodeId (nodeChildren p)]
    describe d =
      show (T.unpack d) ++ " is used by "
        ++ intercalate " and "
             (case parentOfId d of
                [] -> ["the chart root"]
                ps -> map (\p -> "a child of " ++ T.unpack p) ps)

-- | Parse strictly: anything that is not well-formed XML is rejected, so a
-- typo cannot quietly become a different chart.
parseXml :: String -> Either String Element
parseXml src = case X.parseText X.def (TL.pack src) of
  Right doc -> Right (X.documentRoot doc)
  Left err ->
    Left ("document is not well-formed XML: " ++ tidy (unwords (words (show err))))
  where
    -- xml-conduit prints namespace-qualified Name records and Event
    -- constructors, which are noise in a compile error.
    tidy = replace "EventEndElement (" "" . replace ">)" ">"
         . replace "EventEndDocument" "the end of the document" . tidyNames
    tidyNames [] = []
    tidyNames str@(c : cs) = case stripPrefix "Name {nameLocalName = \"" str of
      Just rest ->
        let (nm, rest') = break (== '"') rest
         in case dropWhile (/= '}') rest' of
              '}' : rest'' -> "<" ++ nm ++ ">" ++ tidyNames rest''
              _ -> str
      Nothing -> c : tidyNames cs
    replace from to = go
      where
        go [] = []
        go str@(c : cs) = case stripPrefix from str of
          Just rest -> to ++ go rest
          Nothing -> c : go cs

localName :: Element -> String
localName = T.unpack . nameLocalName . X.elementName

-- Match attributes by local name only, ignoring namespaces.
attr :: String -> Element -> Maybe String
attr k el =
  T.unpack
    <$> listToMaybe [v | (n, v) <- Map.toList (X.elementAttributes el), nameLocalName n == T.pack k]

elChildren :: Element -> [Element]
elChildren el = [e | X.NodeElement e <- X.elementNodes el]

strContent :: Element -> String
strContent el = T.unpack (T.concat [t | X.NodeContent t <- X.elementNodes el])

childrenNamed :: [String] -> Element -> [Element]
childrenNamed names el = [c | c <- elChildren el, localName c `elem` names]

stateChildren :: Element -> [Element]
stateChildren = childrenNamed ["state", "parallel", "final", "history"]

hasInitial :: Element -> Bool
hasInitial el = attr "initial" el /= Nothing || not (null (childrenNamed ["initial"] el))

allowedChildren :: [String]
allowedChildren =
  [ "state", "parallel", "final", "history", "transition", "initial"
  , "onentry", "onexit", "datamodel", "invoke", "donedata"
  ]

-- | Ids, event names and the chart name become Haskell constructors verbatim.
checkConName :: String -> String -> Either String ()
checkConName what raw
  | isConName raw = Right ()
  | otherwise = Left (what ++ " " ++ show raw ++ " must be a Haskell constructor name (start with an upper-case letter, then letters, digits, _ or ')")

-- | An upper-case Haskell identifier, which a constructor, a type and a
-- module all are.
isConName :: String -> Bool
isConName str = case str of
  c : cs -> isUpper c && all (\x -> isAlphaNum x || x == '_' || x == '\'') cs
  [] -> False

-- | A payload type is written as a Haskell type constructor, optionally
-- module-qualified. Nothing more elaborate fits: the @event@ attribute
-- separates fields by spaces, so @Maybe Int@ cannot be told apart from two
-- fields @Maybe@ and @Int@. A type alias covers the rest.
checkTypeName :: String -> String -> Either String ()
checkTypeName what raw
  | all isConName (map T.unpack (T.splitOn (T.pack ".") (T.pack raw))) = Right ()
  | otherwise =
      Left $
        what ++ " " ++ show raw ++ " must be a Haskell type name, optionally module-qualified"
          ++ " (Int, Text, Order.LineItem). A type variable, or a type built with an application,"
          ++ " a list, a tuple or a function arrow, cannot be written here, because the event attribute separates"
          ++ " fields by spaces; give it a type alias and name that"

-- | The prefix of SCXML's automatic completion events.
donePrefix :: Text
donePrefix = T.pack "done.state."

-- | The function names in @<script>@ children of an @<onentry>@, @<onexit>@ or
-- @<transition>@ element, in document order.
scriptsOf :: String -> Element -> Either String [Text]
scriptsOf label el = concat <$> mapM one (elChildren el)
  where
    one c
      | localName c == "script" = case words (strContent c) of
          [name] -> Right [T.pack name]
          _ -> Left (label ++ ": <script> must contain exactly one Haskell function name, got " ++ show (strContent c))
      | localName c `elem` ["raise", "if", "foreach", "log", "assign", "send", "cancel"] =
          Left (label ++ ": executable content <" ++ localName c ++ "> is not supported; use <script>functionName</script>")
      | otherwise = Right []

-- | The child state that entering a compound state (or the @<scxml>@ root)
-- leads to. Required, exactly one, and a direct child: entering must not
-- reach into another state's interior, the same rule transitions follow.
initialOf :: String -> Element -> [StateId] -> Either String StateId
initialOf label el children =
  case (attr "initial" el, childrenNamed ["initial"] el) of
    (Just i, []) -> one "initial attribute" i
    (Nothing, [ie]) -> case childrenNamed ["transition"] ie of
      [t] | Just tg <- attr "target" t -> one "<initial> transition target" tg
      _ -> Left (label ++ ": <initial> must contain exactly one <transition target=...>")
    (Nothing, []) ->
      Left $
        label ++ ": needs an initial attribute naming the child state to enter, for example initial="
          ++ show (maybe "..." T.unpack (listToMaybe children))
    (Just _, _ : _) -> Left (label ++ ": has both an initial attribute and an <initial> element")
    (Nothing, _ : _ : _) -> Left (label ++ ": has more than one <initial> element")
  where
    one what s = case map T.pack (words s) of
      [c]
        | c `elem` children -> Right c
        | otherwise ->
            Left $
              label ++ ": " ++ what ++ " " ++ show (T.unpack c) ++ " must name one of its direct child states ("
                ++ intercalate ", " (map T.unpack children)
                ++ "); entering a state may not reach into another state's interior"
      [] -> Left (label ++ ": empty " ++ what)
      cs ->
        Left $
          label ++ ": " ++ what ++ " names several states (" ++ unwords (map T.unpack cs)
            ++ "); exactly one child state is required"

buildNode :: Element -> P Node
buildNode el = do
  let tag = localName el
  when (tag == "history") $ throwP "<history> states are not supported yet"
  unless (tag `elem` ["state", "parallel", "final"]) $
    throwP ("unexpected element <" ++ tag ++ "> where a state was expected")
  sid <- case attr "id" el of
    Just i -> liftE (checkConName ("<" ++ tag ++ "> id") i) >> pure (T.pack i)
    Nothing -> throwP ("<" ++ tag ++ "> without an id attribute")
  let label = "<" ++ tag ++ " id=\"" ++ T.unpack sid ++ "\">"
  forM_ (elChildren el) $ \c ->
    unless (localName c `elem` allowedChildren) $
      throwP (label ++ ": unexpected child element <" ++ localName c ++ ">")
  (pairs, children) <- buildChildren label el
  trans <- liftE (transitionMap label pairs)
  onEntry <- liftE (concat <$> mapM (scriptsOf (label ++ " <onentry>")) (childrenNamed ["onentry"] el))
  onExit <- liftE (concat <$> mapM (scriptsOf (label ++ " <onexit>")) (childrenNamed ["onexit"] el))
  kind <- case (tag, NE.nonEmpty children) of
    ("parallel", Nothing) -> throwP (label ++ ": <parallel> must contain at least one region")
    ("parallel", Just regions) -> do
      when (hasInitial el) $ throwP (label ++ ": <parallel> cannot specify an initial state; every region is entered")
      forM_ regions $ \r ->
        when (nodeKind r == Final) $
          throwP $
            label ++ ": region " ++ T.unpack (nodeId r) ++ " is a <final> state, which SCXML does not allow"
              ++ " inside <parallel> and which would report the whole <parallel> complete before the other"
              ++ " regions had run. A region must be a state that can be in progress."
      forM_ regions $ \r ->
        unless (Map.null (nodeTransitions r)) $
          throwP $
            T.unpack (nodeId r) ++ " is a region of the <parallel> " ++ T.unpack sid
              ++ " and cannot have transitions: its sibling regions are active at the same time, so leaving it would leave them behind."
              ++ " Declare the transition on " ++ T.unpack sid ++ " or on a state inside " ++ T.unpack (nodeId r) ++ "."
      pure (Parallel regions)
    ("final", _) -> do
      unless (null children) $ throwP (label ++ ": final states cannot contain states")
      unless (Map.null trans) $ throwP (label ++ ": final states cannot have transitions")
      when (hasInitial el) $ throwP (label ++ ": final states cannot specify an initial state")
      pure Final
    (_, Nothing) -> do
      when (hasInitial el) $ throwP (label ++ ": atomic states cannot specify an initial state")
      pure Atomic
    (_, Just kids) -> do
      initial <- liftE (initialOf label el (map nodeId children))
      Compound <$> liftE (initialFirst label initial kids)
  pure
    Node
      { nodeId = sid
      , nodeKind = kind
      , nodeTransitions = trans
      , nodeOnEntry = onEntry
      , nodeOnExit = onExit
      }

-- | Transitions and descendant nodes of an element, numbered in textual order.
buildChildren :: String -> Element -> P ([(Text, StateId)], [Node])
buildChildren label el = go (elChildren el)
  where
    go [] = pure ([], [])
    go (c : rest)
      | localName c == "transition" = do
          t <- buildTransition label c
          (ts, ns) <- go rest
          pure (t : ts, ns)
      | localName c `elem` ["state", "parallel", "final", "history"] = do
          n <- buildNode c
          (ts, ns) <- go rest
          pure (ts, n : ns)
      | otherwise = go rest

-- | The (event, target) pair one @<transition>@ element contributes. The
-- @event@ attribute holds the event name followed by the Haskell types its
-- constructor carries, so @event="Ok Int"@ declares @Ok Int@ and
-- @event="Ok"@ declares @Ok@. Two events reaching one target are two
-- @<transition>@ elements.
buildTransition :: String -> Element -> P (Text, StateId)
buildTransition label el = do
  let evWords = maybe [] words (attr "event" el)
      targets = map T.pack (maybe [] words (attr "target" el))
  when (attr "cond" el /= Nothing) $
    throwP (label ++ ": cond is not supported; make the decision in an <onentry> callback that raises an event instead")
  unless (null (childrenNamed ["script"] el)) $
    throwP (label ++ ": <script> on a transition is not supported; put it in the <onentry> of the target, which receives the triggering event")
  (name, fields) <- case evWords of
    [] -> throwP (label ++ ": transition without an event; eventless transitions are not supported, raise an event from a callback instead")
    (n : fs) -> pure (n, fs)
  target <- case targets of
    [t] -> pure t
    [] -> throwP (label ++ ": transition without a target; to act on an event without leaving the state, target the state itself")
    ts ->
      throwP $
        label ++ ": transition names several targets (" ++ unwords (map T.unpack ts)
          ++ "); a transition enters exactly one state, and entering siblings at once is only meaningful inside a <parallel>, whose regions cannot have transitions"
  case attr "type" el of
    Nothing -> pure ()
    Just "external" -> pure ()
    Just "internal" ->
      throwP (label ++ ": type=\"internal\" is not supported; it can only differ from an external transition for a target inside the source, which is not allowed")
    Just other -> throwP (label ++ ": unknown transition type " ++ show other)
  when ('*' `elem` name) $
    throwP (label ++ ": wildcard event descriptor " ++ show name ++ " is not supported")
  -- A type written with brackets is cut at the spaces inside it, so by the
  -- time the pieces reach checkTypeName they are meaningless. Catch it on the
  -- attribute, where the whole type is still there to show.
  forM_ (attr "event" el) $ \raw ->
    when (any (`elem` "()[],") raw) $
      throwP $
        label ++ ": the event attribute " ++ show raw ++ " writes a type with parentheses,"
          ++ " brackets or a comma. The attribute holds an event name and then one type"
          ++ " constructor per payload field, separated by spaces, so there is nowhere for"
          ++ " those to go: name the type yourself (type Items = [Item]) and write that"
  ev <- case stripPrefix (T.unpack donePrefix) name of
    Just inner -> do
      liftE (checkConName (label ++ " done.state event state") inner)
      unless (null fields) $
        throwP $
          label ++ ": " ++ show name ++ " cannot carry a payload; the chart raises its own"
            ++ " done.state events, so there is nowhere for one to come from"
      pure (Event (T.pack name) [])
    Nothing -> do
      liftE (checkConName (label ++ " event") name)
      forM_ fields $ \f ->
        liftE (checkTypeName (label ++ " event " ++ show name ++ " payload type") f)
      pure (Event (T.pack name) (map T.pack fields))
  seeEvent label ev
  pure (eventName ev, target)

-- | One transition per event, so selection never has to break a tie.
transitionMap :: String -> [(Text, StateId)] -> Either String (Map.Map Text StateId)
transitionMap label pairs = case dups of
  [] -> Right (Map.fromList pairs)
  (e : _) ->
    Left $
      label ++ ": two transitions for the event " ++ show (T.unpack e) ++ " (to "
        ++ intercalate " and " [T.unpack t | (e', t) <- pairs, e' == e]
        ++ "); with no cond there is nothing to choose between them"
  where
    dups = [e | (e : _ : _) <- group (sort (map fst pairs))]

-- | Everything that needs more than one node at a time, which after the level
-- rules is very little: a transition target must be one of the source's
-- siblings, and @done.state.X@ may only be handled on @X@ itself. Both are
-- local to a state and its neighbours, so this is a plain walk.
validate :: Chart -> Either String ()
validate ch = mapM_ (checkNode roots) (NE.toList (chartRoot ch))
  where
    roots = NE.toList (chartRoot ch)
    checkNode siblings n = do
      mapM_ (checkTransition siblings n) (Map.toList (nodeTransitions n))
      let kids = nodeChildren n
      mapM_ (checkNode kids) kids
    checkTransition siblings n (e, tgt) = do
      unless (tgt `elem` map nodeId siblings) $
        Left $
          "transition from " ++ T.unpack (nodeId n) ++ " to " ++ T.unpack tgt
            ++ " crosses levels: a transition must target a sibling of its source, and "
            ++ T.unpack (nodeId n) ++ "'s siblings are "
            ++ intercalate ", " (map (T.unpack . nodeId) siblings)
            ++ ". To leave an enclosing state, declare the transition on that state instead"
      forM_ (T.stripPrefix donePrefix e) $ \target ->
        if target /= nodeId n
          then
            Left $
              T.unpack e ++ " on " ++ T.unpack (nodeId n) ++ ": only " ++ T.unpack target
                ++ " may react to its own completion, so declare this transition on "
                ++ T.unpack target
                ++ ". To carry the completion further out, have " ++ T.unpack target
                ++ " move to a <final> sibling, which completes their parent and raises its own done event"
          else
            unless (completes n) $
              Left $
                T.unpack e ++ " can never fire: " ++ T.unpack target
                  ++ " is not a <parallel> or a <state> with a <final> child"