packages feed

scxml-statecharts (empty) → 0.1.0.0

raw patch · 14 files changed

+2132/−0 lines, 14 filesdep +basedep +containersdep +scxml-statecharts

Dependencies added: base, containers, scxml-statecharts, template-haskell, text, transformers, xml-conduit

Files

+ CHANGELOG.md view
@@ -0,0 +1,57 @@+# Changelog++## 0.1.0.0 -- unreleased++First release.++- `scxml` declaration quasiquoter generating `FsmState`, `FsmEvent`,+  `fsmChart`, `initiateStateMachine` and `notifyStateMachine`.+- Hierarchy (compound states as sum types), parallel regions (as products),+  `<onentry>` and `<onexit>` callbacks named in the XML, entry callbacks that+  raise events, and SCXML `done.state.X` completion events.+- Unmatched events leave the state unchanged, as in SCXML.+- Compile-time validation: strict XML (via `xml-conduit`), state ids, event+  names, callback names, a level rule requiring a transition to target a+  sibling of its source, and a rule forbidding transitions on a region of a+  `<parallel>`. `initial` is required on every compound state and must name a+  direct child; SCXML's "first child in document order" default is not+  supported.+- The evaluator is a single recursive pass over the tree. Because a transition+  may only target a sibling, a state only ever rearranges its own children, so+  there are no least common ancestors, exit-set filters over the whole+  configuration, or conflicts between transitions at different depths. The+  derived index is gone, along with lookup by id, parent links and document+  order. Innermost-wins is now explicit: children are asked first and a state+  acts only if nothing below it did.+- A `<final>` state is rejected as a direct region of a `<parallel>`. SCXML+  does not allow it, and it used to report the whole parallel complete before+  the other regions had run.+- The chart is a tree: a state owns its children as `Node`s, and a compound+  state keeps its initial child first, so a dangling child, a disagreeing+  parent and an initial child that is not one of its own are all+  unrepresentable. Parent and document order are derived into an index rather+  than stored. A compound state's initial child is therefore its first+  generated constructor, which changes derived `Ord` for charts that do not+  write the initial state first.+  Only the interpreter needs the derived index; the parser and the generator+  walk the tree, so a transition target is checked against the source's+  siblings rather than by looking up parents.+- `done.state.X` may only be handled on `X` itself, so completion climbs one+  level at a time through `<final>` states and validation is entirely local to+  a state and its neighbours.+- A transition on an enclosing state acts as a default that an inner state can+  override, since the innermost matching transition wins and only it is taken.+  Documented rather than warned about: a Template Haskell warning becomes an+  error under `-Werror` and cannot be exempted by flag.+- The public API is the `scxml` quasiquoter alone. `serializeStateMachine` and+  `deserializeStateMachine` are generated into the calling module, so a chart+  needs no other import. The remaining modules are not exposed.+- Transitions are a map from event name to target, so two transitions on one+  state for the same event are unrepresentable, and document order never+  decides which transition is taken. A transition naming more than one target+  is rejected; both previously compiled and silently produced a wrong state.+- `Kind` carries each state's children, so an atomic or final state with+  children, a compound state without an initial child, and a `<parallel>`+  without regions are all unrepresentable rather than merely rejected.+- `Show`/`Read` on every generated type, and `toStateIds`/`fromStateIds` for+  storing a state outside Haskell as the SCXML configuration.
+ LICENSE view
@@ -0,0 +1,30 @@+Copyright (c) 2026, Axel Ulmestig++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++    * Redistributions of source code must retain the above copyright+      notice, this list of conditions and the following disclaimer.++    * Redistributions in binary form must reproduce the above+      copyright notice, this list of conditions and the following+      disclaimer in the documentation and/or other materials provided+      with the distribution.++    * Neither the name of the copyright holder nor the names of its+      contributors may be used to endorse or promote products derived+      from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,288 @@+# scxml-statecharts++Define a [statechart](https://statecharts.dev/) in SCXML inside a Haskell+module and get typed states, events and a step function out of it. Compound+states become sum types and parallel states become products, so a value of the+state type is exactly one legal configuration: illegal states are+unrepresentable and `case` is exhaustive.++The library exports one name, the `scxml` quasiquoter. Everything a chart needs+is generated into your own module.++## Example++A monitor that polls something and reports when it is healthy:++```haskell+{-# LANGUAGE QuasiQuotes #-}+module Monitor where++import Scxml.Statechart (scxml)+import Control.Monad.Trans.State.Strict (StateT, gets, modify')++[scxml|+<scxml initial="Idle">+  <state id="Idle">+    <transition event="Check" target="Polling"/>+  </state>+  <state id="Polling" initial="Fetching">+    <onexit><script>recordRun</script></onexit>+    <state id="Fetching">+      <onentry><script>fetchStatus</script></onentry>+      <transition event="Ok" target="Reporting"/>+    </state>+    <state id="Reporting">+      <onentry><script>sendReport</script></onentry>+    </state>+    <transition event="Done" target="Idle"/>+    <transition event="Failed" target="Idle"/>+  </state>+</scxml>+|]++data Monitor = Monitor {healthy :: Bool, runs :: Int, reports :: Int}++-- Signatures for the generated functions are optional and go after the+-- quasiquote, like anything else mentioning the generated types.+initiateStateMachine :: StateT Monitor IO FsmState+notifyStateMachine :: FsmState -> FsmEvent -> StateT Monitor IO FsmState++-- The callbacks named in <script>. They must share one monad, and the type+-- checker enforces it.+fetchStatus, sendReport :: FsmState -> Maybe FsmEvent -> StateT Monitor IO (Maybe FsmEvent)+fetchStatus _ _ = gets (\m -> Just (if healthy m then Ok else Failed))+sendReport _ _ = modify' (\m -> m {reports = reports m + 1}) >> pure (Just Done)++recordRun :: FsmState -> Maybe FsmEvent -> StateT Monitor IO ()+recordRun _ _ = modify' (\m -> m {runs = runs m + 1})+```++The quasiquote generates:++```haskell+data FsmState = Idle | Polling Polling+data Polling  = Fetching | Reporting+data FsmEvent = Check | Ok | Done | Failed++initiateStateMachine    -- enter the initial state, running its entry callbacks+notifyStateMachine      -- deliver one event+serializeStateMachine   :: FsmState -> [Text]+deserializeStateMachine :: [Text] -> Maybe FsmState+```++A compound state becomes a constructor carrying a sum type of the same name,+which Haskell allows since types and constructors live in separate namespaces.+Names in the XML are used verbatim, so they must be valid constructor names+(`PaymentAuthorized`, not `payment.authorized`).++A compound state's initial child is always its first constructor, whether or+not you wrote it first, so derived `Ord` follows that rather than document+order. Use `Ord` for `Map` keys and sorting, not for anything you store.++Do not give a chart module an explicit export list, or the generated functions+you do not call will draw unused-binding warnings.++One `Check` runs the whole cycle. The chart enters `Polling` and `Fetching`,+whose entry callback raises `Ok` or `Failed`, and either way it returns to+`Idle` before `notifyStateMachine` hands back. Anything the callbacks need+lives in the monad, which plays the role of the SCXML datamodel.++## Callbacks++`<script>name</script>` inside `<onentry>` or `<onexit>` names a Haskell+function defined in the same module, after the quasiquote:++```haskell+-- onentry: may decide where to go next by raising an event+name :: FsmState -> Maybe FsmEvent -> m (Maybe FsmEvent)+-- onexit: cleanup only, enforced by the generated code+name :: FsmState -> Maybe FsmEvent -> m ()+```++Entry callbacks receive the state being entered, exit callbacks the state being+left. The event is the one being processed, or `Nothing` during+`initiateStateMachine`.++Returning `Just event` raises it, which is SCXML's `<raise>`. Raised events are+queued and processed before `notifyStateMachine` returns. This is how branching+is expressed: a decision becomes a state whose entry callback raises one of the+events leading out of it, which keeps the branching visible in the chart as+named events and puts the criterion in Haskell where the data is.++A top-level splice and the declarations after it form one declaration group, so+callbacks can be defined after the quasiquote. They have to be, since they+mention the generated types.++Callbacks need not share a constraint, only a monad. Their constraints union+at the generated call site, so one `MonadIO m` callback makes both generated+functions require `MonadIO`, while a `Monad m` callback keeps its own weaker+signature and stays usable elsewhere. Declaring the weakest constraint each+callback needs is therefore still worth it.++## Semantics++`notifyStateMachine` runs `<onexit>` callbacks of exited states, innermost+first, then `<onentry>` callbacks of entered states, outermost first. It then+processes raised events the same way until none is left, and returns.++An event with no matching transition in the current state is ignored, as in+SCXML: the state comes back unchanged and nothing runs. A poll result arriving+after the chart moved on is the typical case. A raised event nothing handles is+dropped too.++### A transition on an enclosing state is a default++When an event arrives, each active state looks for a transition starting at the+innermost active state and working outward, and the first one found wins. So a+transition on an enclosing state applies everywhere inside it, and an inner+state can override it:++```xml+<state id="Processing" initial="Authorizing">+  <state id="Authorizing">...</state>+  <parallel id="Fulfilment">+    ...+    <transition event="Cancel" target="Refunding"/>   <!-- wins while fulfilling -->+  </parallel>+  <state id="Refunding">...</state>+  <transition event="Cancel" target="Cancelled"/>     <!-- applies elsewhere inside -->+</state>+```++`Cancel` refunds while fulfilment is under way and cancels outright anywhere+else in `Processing`. Only the inner transition is taken, never both, so the+outer one is a fallback rather than an additional step. Writing the same event+on a state and on one of its descendants is therefore meaningful, not a+mistake, but it is worth a comment in the chart since the reader has to know+this rule to see which one applies.++Entering a `<final>` state raises `done.state.Parent`, and `done.state.G` when+every region of a parallel grandparent `G` has reached a final state. Each done+event is handled on the state it names, so completion climbs one level at a+time. That is how a parallel state completes:++```haskell+[scxml|+<scxml initial="Fulfilment">+  <parallel id="Fulfilment">+    <state id="Parcel" initial="Packing">+      <state id="Packing"><transition event="Packed" target="Shipped"/></state>+      <final id="Shipped"/>+    </state>+    <state id="Invoice" initial="Unpaid">+      <state id="Unpaid"><transition event="Paid" target="Settled"/></state>+      <final id="Settled"/>+    </state>+    <transition event="done.state.Fulfilment" target="Complete"/>+  </parallel>+  <final id="Complete"/>+</scxml>+|]+```++```haskell+data FsmState = Fulfilment Parcel Invoice | Complete+data Parcel   = Packing | Shipped+data Invoice  = Unpaid | Settled+data FsmEvent = Packed | Paid | DoneFulfilment | DoneParcel | DoneInvoice+```++The parallel state is a product, so both regions advance independently and the+type cannot represent one of them being absent. `done.state.X` becomes the+constructor `DoneX`. Whichever region finishes second fires it:++```haskell+Fulfilment Packing Unpaid  --Packed-->  Fulfilment Shipped Unpaid  --Paid-->  Complete+```++## Storing a state++Every generated type derives `Show`, `Read`, `Eq` and `Ord`, so `Show` and+`Read` round-trip exactly and are convenient in tests. For a state that+outlives the process, such as one parked in a database between AWS Lambda+invocations, use the generated pair:++```haskell+serializeStateMachine (Fulfilment Shipped Unpaid)+  == ["Fulfilment","Invoice","Parcel","Shipped","Unpaid"]+```++This is SCXML's own notion of a chart's state, so it is portable to another+implementation of the same chart, readable in a log, and queryable as a text or+JSON array in Postgres.++The array is a set, so nothing positional leaks in. Order and duplicates in the+input do not matter, and reordering the regions of a `<parallel>` in the SCXML+does not change it, even though the Haskell field order flips. Positional+formats such as `Show` do not survive that edit.++Deserializing returns `Maybe` and validates by round-tripping, so an incomplete+set, an unknown id, or a list that merely starts like a valid configuration are+rejected rather than decoded into some other state. That matters when a chart is+redeployed while states are in flight: a value stored under the old chart fails+loudly and you migrate it deliberately.++Two things not to persist: `Ord` on states and `fromEnum` on events. Both are+positional, so adding a state or an event changes them.++JSON is two lines in your own module, so the library does not depend on+`aeson`:++```haskell+instance ToJSON FsmState where+  toJSON = toJSON . serializeStateMachine+instance FromJSON FsmState where+  parseJSON v = parseJSON v >>= maybe (fail "stale FsmState") pure . deserializeStateMachine+```++## Differences from SCXML++The quasiquoter is intolerant by design, and stricter than the specification.+A chart that compiles is a chart that runs. Loosening a rule later is a+compatible change, so the defaults are tight.++- **No `cond` guards and no eventless transitions.** Raise an event from an+  entry callback instead.+- **A transition must target a sibling of its source.** Events never cross+  levels. To leave an enclosing state, declare the transition on that state,+  where it applies anywhere inside it.+- **`initial` is required on every compound state** and must name a direct+  child, which the model records by keeping that child first. The+  specification's "first child in document order" default is not supported,+  because it makes the entry point depend on the order children happen to be+  written in.+- **One transition per state per event**, held as a map from event name to+  target, so nothing has to break a tie. `event="A B"` is still shorthand for+  two transitions to the same target.+- **`done.state.X` may only be handled on `X` itself.** Since a transition+  also targets a sibling, completion climbs one level at a time: a state that+  finishes moves to a `<final>` sibling, which completes their parent and+  raises its own done event. Listening for another region's completion from+  inside a sibling region is not supported; raise your own event from the final+  state's `<onentry>` if you want that.+- **State ids and event names must be Haskell constructor names**, and ids must+  be unique across the whole chart, which SCXML requires anyway.+- **Parsing is strict.** Malformed XML cannot quietly nest one state inside+  another.+- **A `<final>` may not be a direct region of a `<parallel>`.** SCXML forbids+  it, and a region must be something that can be in progress.+- **Not supported yet:** `<history>` states, wildcard event descriptors,+  executable content other than `<script>`, and a `<parallel>` directly inside+  another `<parallel>`, which can almost always be flattened into one.++## Building++```+cabal build+cabal test+```++The toolchain comes from a Nix flake. With direnv, `direnv allow` puts GHC+9.10.3, cabal and haskell-language-server on the path on entering the+directory; without it, `nix develop`. Two further shells hold the GHCs a+Hackage or Stackage builder is likely to pick, for checking a release:++```+nix develop .#ghc9124 --command cabal test+nix develop .#ghc9141 --command cabal test+```
+ scxml-statecharts.cabal view
@@ -0,0 +1,90 @@+cabal-version:      3.0+name:               scxml-statecharts+version:            0.1.0.0+synopsis:           Typed statecharts from SCXML, via Template Haskell+description:+    Define a statechart (<https://statecharts.dev/>) in SCXML inside a Haskell+    module and get typed states, events and a step function out of it.+    .+    > [scxml|+    > <scxml initial="Draft">+    >   <state id="Draft"><transition event="Submit" target="Review"/></state>+    >   <state id="Review">+    >     <onentry><script>notifyReviewers</script></onentry>+    >     <transition event="Approve" target="Done"/>+    >   </state>+    >   <final id="Done"/>+    > </scxml>+    > |]+    .+    generates @FsmState@, @FsmEvent@ and the functions+    @initiateStateMachine :: m FsmState@ and+    @notifyStateMachine :: FsmState -> FsmEvent -> m FsmState@, which call the+    callbacks named in the @\<script\>@ elements. Compound states become sum+    types and parallel states become products, so a value of @FsmState@ is+    exactly one legal configuration: illegal states are unrepresentable and+    @case@ is exhaustive. Names in the XML are used verbatim as Haskell+    constructor names.+    .+    Hierarchy, parallel regions, entry and exit callbacks and SCXML's+    @done.state@ completion events are supported. @cond@ guards and eventless+    transitions are deliberately not: a decision becomes a state whose entry+    callback raises one of the events leading out of it, which keeps the+    branching visible in the chart. See the README for the full mapping and+    the list of unsupported SCXML features.++homepage:           https://github.com/AxelUlmestig/scxml-statecharts+bug-reports:        https://github.com/AxelUlmestig/scxml-statecharts/issues+license:            BSD-3-Clause+license-file:       LICENSE+author:             Axel Ulmestig+maintainer:         axel.ulmestig@gmail.com+copyright:          (c) 2026 Axel Ulmestig+category:           Control, Language+build-type:         Simple+tested-with:        GHC == 9.10.3 || == 9.12.4+extra-doc-files:+    README.md+    CHANGELOG.md++source-repository head+    type:     git+    location: https://github.com/AxelUlmestig/scxml-statecharts++common shared+    default-language: GHC2021+    ghc-options:      -Wall++library+    import:           shared+    hs-source-dirs:   src+    exposed-modules:+        Scxml.Statechart+    -- Reachable from generated code, which refers to them by names Template+    -- Haskell resolved at compile time, so they need not be exposed.+    other-modules:+        Scxml.Statechart.Def+        Scxml.Statechart.Interpret+        Scxml.Statechart.Model+        Scxml.Statechart.Parse+        Scxml.Statechart.Run+        Scxml.Statechart.TH+    build-depends:+        base             >=4.18   && <5,+        containers       >=0.6.6  && <0.9,+        template-haskell >=2.20   && <2.24,+        text             >=2.0    && <2.2,+        xml-conduit      >=1.9    && <1.11++test-suite spec+    import:           shared+    type:             exitcode-stdio-1.0+    hs-source-dirs:   test+    main-is:          Main.hs+    other-modules:    Reordered+                      Overrides+    build-depends:+        base              >=4.18  && <5,+        scxml-statecharts,+        text              >=2.0   && <2.2,+        transformers      >=0.6   && <0.7
+ src/Scxml/Statechart.hs view
@@ -0,0 +1,28 @@+-- | Typed statecharts generated from SCXML. Write the chart as SCXML in a+-- 'scxml' quasiquote at the top level of a module and get the state and event+-- types, and the functions that run them, generated into that module. See+-- 'scxml' below for a worked example.+--+-- A compound state becomes a sum type and a parallel state a product, so a+-- value of the generated @FsmState@ is exactly one legal configuration:+-- illegal states are unrepresentable and @case@ is exhaustive. State ids and+-- event names are used verbatim as constructor names, so the generated names+-- are fixed and a module holds one chart. Callbacks are defined in the same+-- module, after the quasiquote, and must all live in the same monad, which the+-- type checker enforces.+--+-- Signatures for the two generated functions are optional. They are inferred+-- when the callbacks are in a concrete monad; when the callbacks are+-- polymorphic, @initiateStateMachine@ takes no arguments and so needs either+-- a signature or @NoMonomorphismRestriction@.+--+-- Two more functions are generated for storing a state outside Haskell:+--+-- > serializeStateMachine   :: FsmState -> [Text]+-- > deserializeStateMachine :: [Text] -> Maybe FsmState+--+-- This module exports only the quasiquoter. Everything a chart needs is+-- generated into your own module, so there is nothing else to import.+module Scxml.Statechart (scxml) where++import Scxml.Statechart.TH (scxml)
+ src/Scxml/Statechart/Def.hs view
@@ -0,0 +1,23 @@+-- | The typed chart definition that generated code produces.+module Scxml.Statechart.Def (Def (..)) where++import Data.Set (Set)+import Data.Text (Text)++import Scxml.Statechart.Model (Chart, StateId)++-- | Ties a chart's generated types together with the untyped chart the+-- interpreter runs. @s@ is the state type and @ev@ the event type. The @scxml@+-- quasiquoter generates one of these per chart.+data Def s ev = Def+  { defChart         :: Chart+  , defEventName     :: ev -> Text+  , defEventFromName :: Text -> Maybe ev+    -- ^ total: 'Nothing' means the generated event type has no constructor+    -- for that name, which the generator makes impossible for names the+    -- interpreter can produce+  , defToConfig      :: s -> Set StateId+    -- ^ the set of active state ids described by a typed state+  , defFromConfig    :: Set StateId -> Maybe s+    -- ^ rebuild the typed state from a configuration produced by the interpreter+  }
+ src/Scxml/Statechart/Interpret.hs view
@@ -0,0 +1,284 @@+-- | 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)
+ src/Scxml/Statechart/Model.hs view
@@ -0,0 +1,103 @@+{-# LANGUAGE DeriveLift #-}+-- | The untyped statechart model. This is what the SCXML parser produces and+-- what the evaluator runs on. The Template Haskell layer generates typed+-- wrappers around it.+--+-- The chart is a tree and nothing else: a state owns its children, so a+-- dangling child cannot be represented, and there is no parent link or+-- document index to disagree with the structure. Since a transition may only+-- target a sibling, every consumer works by walking the tree, so none of that+-- would have a reader anyway.+module Scxml.Statechart.Model+  ( -- * The tree+    StateId+  , Kind (..)+  , Node (..)+  , Chart (..)+  , nodeChildren+  , childrenOfKind+  , initialChild+  , isParallel+  , completes+  , chartStates+  ) where++import Data.List.NonEmpty (NonEmpty ((:|)))+import qualified Data.List.NonEmpty as NE+import Data.Map.Strict (Map)+import Data.Text (Text)+import Language.Haskell.TH.Syntax (Lift)++-- | A state's @id@ attribute, which is also its Haskell constructor name.+type StateId = Text++-- | What kind of state a node is, together with the children it owns:+--+-- * 'Atomic' is a leaf and 'Final' is terminal, so neither has children.+-- * 'Compound' has children of which exactly one is active. The first is the+--   one entering it leads to, so a compound state cannot lack an initial+--   child or name one that is not its own.+-- * 'Parallel' has regions, all of which are active at once, so there is no+--   initial one to choose. Their order is document order.+data Kind+  = Atomic+  | Compound (NonEmpty Node)+  | Parallel (NonEmpty Node)+  | Final+  deriving (Eq, Show, Lift)++-- | One state and everything inside it.+data Node = Node+  { nodeId          :: StateId+  , nodeKind        :: Kind+  , nodeTransitions :: Map Text StateId+    -- ^ event name to the sibling state it enters. At most one transition per+    -- event, so nothing has to break a tie.+  , nodeOnEntry     :: [Text]        -- ^ names of @<onentry><script>@ callbacks+  , nodeOnExit      :: [Text]        -- ^ names of @<onexit><script>@ callbacks+  }+  deriving (Eq, Show, Lift)++-- | A whole chart. This is what the quasiquoter lifts into the generated code.+data Chart = Chart+  { chartName   :: Maybe Text+  , chartRoot   :: NonEmpty Node -- ^ children of @<scxml>@; the first is entered+  , chartEvents :: [Text]        -- ^ every event a transition names, in document order+  }+  deriving (Eq, Show, Lift)++-- | Children of a state, the first being its initial child when it has one.+nodeChildren :: Node -> [Node]+nodeChildren = childrenOfKind . nodeKind++-- | Children of a kind, the first being its initial child when it has one.+childrenOfKind :: Kind -> [Node]+childrenOfKind Atomic = []+childrenOfKind Final = []+childrenOfKind (Compound cs) = NE.toList cs+childrenOfKind (Parallel rs) = NE.toList rs++-- | The child that entering a compound state leads to.+initialChild :: Kind -> Maybe Node+initialChild (Compound (c :| _)) = Just c+initialChild _ = Nothing++-- | Whether every child is active at once.+isParallel :: Kind -> Bool+isParallel (Parallel _) = True+isParallel _ = False++-- | Can this state ever raise its @done.state@ event? A parallel state can,+-- once every region is final; a compound state needs a @<final>@ child.+completes :: Node -> Bool+completes n = case nodeKind n of+  Parallel _ -> True+  Compound cs -> any ((== Final) . nodeKind) (NE.toList cs)+  _ -> False++-- | Every state in the chart, in document order. Needs no index: it is the+-- pre-order walk of the tree.+chartStates :: Chart -> [Node]+chartStates = concatMap preorder . NE.toList . chartRoot+  where+    preorder n = n : concatMap preorder (nodeChildren n)
+ src/Scxml/Statechart/Parse.hs view
@@ -0,0 +1,385 @@+-- | 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.+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 event names in the order they are first+-- seen. Document order is derived from the tree, so nothing counts here.+newtype P a = P {runP :: [Text] -> Either String (a, [Text])}++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 name at its first occurrence in the document.+seeEvent :: Text -> P ()+seeEvent e = P $ \seen -> Right ((), if e `elem` seen then seen else seen ++ [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, events) <- 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 = events+          }+  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 = case raw of+  c : cs | isUpper c && all (\x -> isAlphaNum x || x == '_' || x == '\'') cs -> Right ()+  _ -> Left (what ++ " " ++ show raw ++ " must be a Haskell constructor name (start with an upper-case letter, then letters, digits, _ or ')")++-- | 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) pairs one @<transition>@ element contributes. SCXML+-- allows several event names on one element, which is only shorthand for+-- several transitions with the same target.+buildTransition :: String -> Element -> P [(Text, StateId)]+buildTransition label el = do+  let events = 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")+  when (null events) $+    throwP (label ++ ": transition without an event; eventless transitions are not supported, raise an event from a callback instead")+  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)+  forM_ events $ \e ->+    when ('*' `elem` e) $+      throwP (label ++ ": wildcard event descriptor " ++ show e ++ " is not supported")+  forM_ events $ \e -> case stripPrefix (T.unpack donePrefix) e of+    Just inner -> liftE (checkConName (label ++ " done.state event state") inner)+    Nothing -> liftE (checkConName (label ++ " event") e)+  mapM_ (seeEvent . T.pack) events+  pure [(T.pack e, target) | e <- events]++-- | 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"
+ src/Scxml/Statechart/Run.hs view
@@ -0,0 +1,72 @@+-- | Running a chart against a t'Def'. The functions generated by the @scxml@+-- quasiquoter are thin wrappers over these with t'Hooks' dispatching to the+-- callbacks named in the SCXML.+module Scxml.Statechart.Run+  ( Hooks (..)+  , Phase (..)+  , entryAction+  , exitAction+  , start+  , stepOrStay+  ) where++import Data.Maybe (fromMaybe)+import qualified Data.Set as Set+import Data.Text (Text)+import qualified Data.Text as T++import Scxml.Statechart.Def+import qualified Scxml.Statechart.Interpret as I+import Scxml.Statechart.Interpret (Phase (..))+import Scxml.Statechart.Model (StateId)++-- | How to run the callbacks named in @<script>@ elements.+newtype Hooks m s ev = Hooks+  { runAction :: Phase -> Text -> s -> Maybe ev -> m (Maybe ev)+    -- ^ run the named callback with the state it observes (the state being+    -- left for 'OnExit', the state being entered otherwise) and the event+    -- being processed ('Nothing' during 'start'); returns an event to raise+  }++-- | Marks an entry callback, which returns the event it raises, if any.+-- Raised events are queued and processed before the current step returns,+-- like SCXML's @<raise>@. Only here so that a callback with the wrong type+-- gets an error pointing at it.+entryAction :: m (Maybe ev) -> m (Maybe ev)+entryAction = id++-- | Marks an exit callback, which may not raise events. Only here so that a+-- callback with the wrong type gets an error pointing at it.+exitAction :: m () -> m ()+exitAction = id++toInterp :: Monad m => Def s ev -> Hooks m s ev -> I.Callbacks m+toInterp def h =+  I.Callbacks $ \phase name cfg ev ->+    fmap (defEventName def) <$> runAction h phase name (unsafeFromConfig def cfg) (typedEvent def <$> ev)++-- | The generator emits a constructor for every event name the interpreter can+-- produce, including a @done.state@ event for every state that can complete,+-- so this cannot fail for a chart the quasiquoter built.+typedEvent :: Def s ev -> Text -> ev+typedEvent def t =+  fromMaybe+    (error ("Statechart: no constructor for the event " ++ show (T.unpack t) ++ "; this is a bug in scxml-statecharts"))+    (defEventFromName def t)++unsafeFromConfig :: Def s ev -> Set.Set StateId -> s+unsafeFromConfig def cfg =+  fromMaybe+    (error ("Statechart: interpreter produced an invalid configuration: " ++ show (map T.unpack (Set.toList cfg))))+    (defFromConfig def cfg)++-- | Enter the initial state, running entry callbacks and any events they raise.+start :: Monad m => Def s ev -> Hooks m s ev -> m s+start def h = unsafeFromConfig def <$> I.start (defChart def) (toInterp def h)++-- | Deliver one event, staying in the current state when no transition is+-- enabled for it, which is SCXML's behaviour for an unmatched event.+stepOrStay :: Monad m => Def s ev -> Hooks m s ev -> s -> ev -> m s+stepOrStay def h s e =+  maybe s (unsafeFromConfig def)+    <$> I.macrostep (defChart def) (toInterp def h) (defToConfig def s) (defEventName def e)
+ src/Scxml/Statechart/TH.hs view
@@ -0,0 +1,373 @@+{-# LANGUAGE TemplateHaskell #-}+-- | The @scxml@ quasiquoter. Used at the top level of a module:+--+-- @+-- [scxml| <scxml initial="Draft"> ... </scxml> |]+-- @+--+-- The generated names are fixed, so a module holds one chart:+--+-- * @data FsmState@: one constructor per child of @<scxml>@, named exactly as+--   the state id. A compound state becomes a constructor carrying a sum type+--   of the same name as the state; a parallel state becomes a constructor+--   with one field per compound region; atomic and final states are nullary.+-- * @data FsmEvent@: one constructor per event name, verbatim, plus @DoneX@+--   for SCXML's automatic @done.state.X@ completion events.+-- * @fsmChart :: Def FsmState FsmEvent@, for "Scxml.Statechart.Run".+-- * @serializeStateMachine :: FsmState -> [Text]@ and+--   @deserializeStateMachine :: [Text] -> Maybe FsmState@, which store a state+--   as the set of active state ids and read it back, rejecting anything that+--   is not a configuration of this chart.+-- * @initiateStateMachine@ and @notifyStateMachine@, which run the chart+--   calling the callbacks named in @<script>@ elements. Callbacks are looked+--   up by name in the module containing the quasiquote (they may be defined+--   after it) and must share one monad, which the type checker enforces:+--+-- @+-- initiateStateMachine :: m FsmState+-- notifyStateMachine   :: FsmState -> FsmEvent -> m FsmState+--+-- entryCallback :: FsmState -> Maybe FsmEvent -> m (Maybe FsmEvent)+-- exitCallback  :: FsmState -> Maybe FsmEvent -> m ()+-- @+--+-- Every generated type derives @Show@, @Read@, @Eq@ and @Ord@, and the event+-- type also derives @Enum@ and @Bounded@. For storing a state outside+-- Haskell, prefer the generated @serializeStateMachine@ over @Show@.+module Scxml.Statechart.TH (scxml) where++import Control.Monad (forM, unless)+import Data.Char (isAlphaNum, isLower, isUpper)+import Data.List (nub)+import qualified Data.Map.Strict as Map+import qualified Data.List.NonEmpty as NE+import qualified Data.Set as Set+import Data.Text (Text)+import qualified Data.Text as T+import Language.Haskell.TH+import Language.Haskell.TH.Quote+import Language.Haskell.TH.Syntax (lift)++import Scxml.Statechart.Def+import qualified Scxml.Statechart.Interpret as I+import Scxml.Statechart.Model+import Scxml.Statechart.Parse+import qualified Scxml.Statechart.Run as Run++-- | Turns SCXML into a statechart. Usable only at the top level of a module,+-- where it declares the state and event types and the functions that run the+-- chart. A light switch, whose two states each announce themselves on entry:+--+-- > {-# LANGUAGE QuasiQuotes #-}+-- > module LightSwitch where+-- >+-- > import Scxml.Statechart (scxml)+-- >+-- > [scxml|+-- > <scxml initial="Off">+-- >   <state id="Off">+-- >     <onentry><script>report</script></onentry>+-- >     <transition event="Flip" target="On"/>+-- >   </state>+-- >   <state id="On">+-- >     <onentry><script>report</script></onentry>+-- >     <transition event="Flip" target="Off"/>+-- >   </state>+-- > </scxml>+-- > |]+-- >+-- > report :: FsmState -> Maybe FsmEvent -> IO (Maybe FsmEvent)+-- > report st _ = print st >> pure Nothing+--+-- State ids and event names are used verbatim, so that declares @FsmState@+-- with constructors @Off@ and @On@, @FsmEvent@ with @Flip@, and:+--+-- > initiateStateMachine :: IO FsmState+-- > notifyStateMachine   :: FsmState -> FsmEvent -> IO FsmState+--+-- Callbacks are ordinary functions in the same module, written after the+-- quasiquote, and are found by the name in @\<script\>@:+--+-- > ghci> off <- initiateStateMachine+-- > Off+-- > ghci> on <- notifyStateMachine off Flip+-- > On+--+-- The callbacks all share one monad, which the type checker enforces. @IO@ is+-- enough to print; when a chart has to carry something along, put the+-- callbacks in a @StateT@ over it and the generated functions follow suit:+--+-- > record :: FsmState -> Maybe FsmEvent -> StateT [FsmState] IO (Maybe FsmEvent)+-- > record st _ = modify (st :) >> pure Nothing+-- >+-- > -- initiateStateMachine :: StateT [FsmState] IO FsmState+-- > -- notifyStateMachine   :: FsmState -> FsmEvent -> StateT [FsmState] IO FsmState+--+-- Returning @Just event@ instead of @Nothing@ raises that event, which is how+-- a callback decides where the chart goes next. The generated names are fixed,+-- so a module holds one chart.+scxml :: QuasiQuoter+scxml =+  QuasiQuoter+    { quoteExp = const unsupported+    , quotePat = const unsupported+    , quoteType = const unsupported+    , quoteDec = generate+    }+  where+    unsupported = fail "scxml: only usable as a top-level declaration, e.g. [scxml| <scxml ...> |]"++-- | A compound-like node (the root or a @<state>@ with children) becomes its+-- own data type with helpers converting to and from configurations.+data Group = Group+  { gType     :: Name+  , gChildren :: [Node]+  , gTo       :: Name+  , gFrom     :: Name+  }++generate :: String -> Q [Dec]+generate src = do+  ch <- orFail (parseScxml src)+  -- Everything here is a walk of the tree: a node carries its own children,+  -- so nothing needs looking up by id.+  let states = chartStates ch+  let stateT = mkName "FsmState"+      eventT = mkName "FsmEvent"+      defName = mkName "fsmChart"+      startName = mkName "initiateStateMachine"+      stepName = mkName "notifyStateMachine"+      toIdsName = mkName "serializeStateMachine"+      fromIdsName = mkName "deserializeStateMachine"+      -- A compound state's type has the same name as its constructor; Haskell+      -- keeps types and constructors in separate namespaces.+      nameFor sid = mkName (T.unpack sid)+      compounds = [n | n <- states, Compound _ <- [nodeKind n]]++  -- Callback names+  let entryActions = nub (concatMap nodeOnEntry states)+      exitActions = nub (concatMap nodeOnExit states)+  mapM_ (orFail . checkVarName) (nub (entryActions ++ exitActions))++  -- Events: those named in transitions, in document order, then done events of+  -- states that can complete but that no transition mentions.+  let referenced = chartEvents ch+      doneEvents = [I.doneEventName (nodeId n) | n <- states, completes n]+      events = referenced ++ filter (`notElem` referenced) doneEvents+      eventCon e = case T.stripPrefix (T.pack "done.state.") e of+        Just sid -> (mkName ("Done" ++ T.unpack sid), "completion event " ++ show (T.unpack e))+        Nothing -> (mkName (T.unpack e), "event " ++ show (T.unpack e))+      eventCons = map eventCon events++  groups <- forM (Nothing : map Just compounds) $ \g -> do+    let ty = maybe stateT (nameFor . nodeId) g+    to <- newName ("toCfg_" ++ nameBase ty)+    from <- newName ("fromCfg_" ++ nameBase ty)+    let children = maybe (NE.toList (chartRoot ch)) nodeChildren g+    pure (g, Group ty children to from)+  let groupMap = Map.fromList [(nodeId n, grp) | (Just n, grp) <- groups]+  rootGroup <- case groups of+    (_, g) : _ -> pure g+    [] -> fail "scxml: internal error, no root group"+  let groupOf sid = case Map.lookup sid groupMap of+        Just grp -> pure grp+        Nothing -> fail ("scxml: internal error, no group for " ++ T.unpack sid)++  -- Every generated constructor and type, with its origin, so clashes give a+  -- readable error instead of "Multiple declarations".+  let stateCons =+        [ (nameFor (nodeId n), "state " ++ show (T.unpack (nodeId n)))+        | n <- concatMap (gChildren . snd) groups+        ]+      typeNames =+        [(stateT, "the state type"), (eventT, "the event type")]+          ++ [(gType grp, "compound state " ++ show (T.unpack (nodeId n))) | (Just n, grp) <- groups]+  checkClashes "constructor" (stateCons ++ eventCons)+  checkClashes "type" typeNames++  stateDecs <- concat <$> mapM (groupDecs nameFor groupOf . snd) groups+  eventDec <-+    dataD (cxt []) eventT [] Nothing [normalC c [] | (c, _) <- eventCons]+      [derivClause Nothing (map conT (if null eventCons then [''Show, ''Read, ''Eq, ''Ord] else [''Show, ''Read, ''Eq, ''Ord, ''Enum, ''Bounded]))]++  let eventNameE+        | null eventCons = [| \_ -> error "eventName: chart has no events" |]+        | otherwise = lamCaseE [match (conP c []) (normalB (lift e)) [] | ((c, _), e) <- zip eventCons events]+      eventFromNameE = do+        t <- newName "t"+        lam1E (varP t) $+          foldr+            (\((c, _), e) rest -> [| if $(varE t) == $(lift e) then Just $(conE c) else $rest |])+            [| Nothing |]+            (zip eventCons events)++  defSig <- sigD defName [t| Def $(conT stateT) $(conT eventT) |]+  defDec <-+    valD (varP defName)+      (normalB+        [| Def+             { defChart = $(lift ch)+             , defEventName = $eventNameE+             , defEventFromName = $eventFromNameE+             , defToConfig = $(varE (gTo rootGroup))+             , defFromConfig = $(varE (gFrom rootGroup))+             } |])+      []++  -- Hooks dispatching to the callbacks named in the SCXML. Entry callbacks+  -- return m (Maybe FsmEvent), exit callbacks m (). Inlined into each+  -- generated function rather than shared, so a polymorphic monad does not+  -- hit the monomorphism restriction.+  -- Underscore-prefixed so that a chart with no callbacks of some phase does+  -- not emit an unused-match warning in the user's module.+  let hooksE = do+        phase <- newName "_phase"+        name <- newName "_name"+        st <- newName "_st"+        ev <- newName "_ev"+        let call fn = [| $(varE (mkName (T.unpack fn))) $(varE st) $(varE ev) |]+            entryChain =+              foldr (\fn rest -> [| if $(varE name) == $(lift fn) then Run.entryAction $(call fn) else $rest |])+                    [| pure Nothing |] entryActions+            exitChain =+              foldr (\fn rest -> [| if $(varE name) == $(lift fn) then Run.exitAction $(call fn) else $rest |])+                    [| pure () |] exitActions+        body <- caseE (varE phase)+          [ match (conP 'Run.OnExit []) (normalB [| $exitChain >> pure Nothing |]) []+          , match (conP 'Run.OnEntry []) (normalB entryChain) []+          ]+        [| Run.Hooks $(lamE [varP phase, varP name, varP st, varP ev] (pure body)) |]+  startDec <- valD (varP startName) (normalB [| Run.start $(varE defName) $hooksE |]) []+  stepDec <- do+    st <- newName "st"+    ev <- newName "ev"+    funD stepName [clause [varP st, varP ev] (normalB [| Run.stepOrStay $(varE defName) $hooksE $(varE st) $(varE ev) |]) []]++  -- Storing a state outside Haskell, as the set of active state ids. Generated+  -- rather than exported so that a chart needs no imports beyond the+  -- quasiquoter itself.+  let toCfg = varE (gTo rootGroup)+      fromCfg = varE (gFrom rootGroup)+  toIdsSig <- sigD toIdsName [t| $(conT stateT) -> [Text] |]+  toIdsDec <- do+    x <- newName "st"+    funD toIdsName [clause [varP x] (normalB [| Set.toAscList ($toCfg $(varE x)) |]) []]+  fromIdsSig <- sigD fromIdsName [t| [Text] -> Maybe $(conT stateT) |]+  fromIdsDec <- do+    ids <- newName "ids"+    given <- newName "given"+    st <- newName "st"+    let body =+          [| let $(varP given) = Set.fromList $(varE ids)+              in case $fromCfg $(varE given) of+                   -- Round-trip so that a set which merely starts like a valid+                   -- one, or is missing part of a configuration, is rejected.+                   Just $(varP st) | $toCfg $(varE st) == $(varE given) -> Just $(varE st)+                   _ -> Nothing |]+    funD fromIdsName [clause [varP ids] (normalB body) []]++  pure (stateDecs ++ [eventDec, defSig, defDec, startDec, stepDec, toIdsSig, toIdsDec, fromIdsSig, fromIdsDec])++-- | Data type plus configuration conversions for one compound-like node.+groupDecs :: (StateId -> Name) -> (StateId -> Q Group) -> Group -> Q [Dec]+groupDecs nameFor groupOf grp = do+  shapes <- mapM childShape (gChildren grp)+  let dataDec =+        dataD (cxt []) (gType grp) [] Nothing+          [normalC con [bangType (bang noSourceUnpackedness noSourceStrictness) (conT f) | f <- fields] | (con, fields, _, _) <- shapes]+          [derivClause Nothing (map conT [''Show, ''Read, ''Eq, ''Ord])]+      toDec = do+        x <- newName "x"+        alts <- forM shapes $ \(con, fields, toE, _) -> do+          vars <- mapM (const (newName "r")) fields+          match (conP con (map varP vars)) (normalB (toE vars)) []+        funD (gTo grp) [clause [varP x] (normalB (caseE (varE x) (map pure alts))) []]+      fromDec = do+        cfg <- newName "cfg"+        let body =+              foldr+                (\(n, (_, _, _, rebuild)) rest -> [| if Set.member $(lift (nodeId n)) $(varE cfg) then $(rebuild cfg) else $rest |])+                [| Nothing |]+                (zip (gChildren grp) shapes)+        funD (gFrom grp) [clause [varP cfg] (normalB body) []]+  sequence+    [ dataDec+    , sigD (gTo grp) [t| $(conT (gType grp)) -> Set.Set Text |]+    , toDec+    , sigD (gFrom grp) [t| Set.Set Text -> Maybe $(conT (gType grp)) |]+    , fromDec+    ]+  where+    -- For one child: (constructor, field types, config-of-fields, rebuild-from-config)+    childShape :: Node -> Q (Name, [Name], [Name] -> Q Exp, Name -> Q Exp)+    childShape node = do+      let sid = nodeId node+          con = nameFor sid+      case nodeKind node of+        Compound _ -> do+          sub <- groupOf sid+          pure+            ( con+            , [gType sub]+            , \vs -> case vs of+                [v] -> [| Set.insert $(lift sid) ($(varE (gTo sub)) $(varE v)) |]+                _ -> fail "scxml: internal error, compound state expects exactly one field"+            , \cfg -> [| fmap $(conE con) ($(varE (gFrom sub)) $(varE cfg)) |]+            )+        Parallel regionNodes -> do+          regions <- forM (NE.toList regionNodes) $ \rn -> case nodeKind rn of+            Compound _ -> Just <$> groupOf (nodeId rn)+            Parallel _ ->+              fail $+                "scxml: <parallel> " ++ T.unpack (nodeId rn) ++ " is directly inside another <parallel>, which is not supported."+                  ++ " Flatten it: its regions can become regions of the outer <parallel>, since all of them are active at once either way."+                  ++ " The only thing flattening loses is a done.state event for the inner one on its own."+            _ -> pure Nothing+          let regionIds = map nodeId (NE.toList regionNodes)+              fieldTypes = [gType g | Just g <- regions]+              toE vs =+                let go [] _ = []+                    go ((r, Nothing) : rest) vars = [| Set.singleton $(lift r) |] : go rest vars+                    go ((r, Just g) : rest) (v : vars) = [| Set.insert $(lift r) ($(varE (gTo g)) $(varE v)) |] : go rest vars+                    go _ [] = error "scxml: internal error, region/field mismatch"+                 in [| Set.insert $(lift sid) (Set.unions $(listE (go (zip regionIds regions) vs))) |]+              rebuild cfg = foldl (\acc g -> [| $acc <*> $(varE (gFrom g)) $(varE cfg) |]) [| pure $(conE con) |] [g | Just g <- regions]+          pure (con, fieldTypes, toE, rebuild)+        _ ->+          pure+            ( con+            , []+            , \_ -> [| Set.singleton $(lift sid) |]+            , \_ -> [| Just $(conE con) |]+            )++checkClashes :: String -> [(Name, String)] -> Q ()+checkClashes what named = do+  let byName = Map.fromListWith (++) [(nameBase n, [origin]) | (n, origin) <- named]+      clashes = [(n, os) | (n, os) <- Map.toList byName, length os > 1]+  unless (null clashes) $+    fail $ unlines $+      ("scxml: generated " ++ what ++ " names clash; rename one of the SCXML identifiers:")+        : ["  " ++ n ++ " would be generated for " ++ commaList (reverse os) | (n, os) <- clashes]+  where+    commaList [a, b] = a ++ " and " ++ b+    commaList xs = foldr1 (\a b -> a ++ ", " ++ b) xs++-- | Callback names in @<script>@ must be Haskell variable names, optionally+-- qualified (@Inventory.reserve@).+checkVarName :: Text -> Either String ()+checkVarName raw+  | ok = Right ()+  | otherwise = Left ("<script> callback " ++ show (T.unpack raw) ++ " is not a Haskell function name (expected something like reserveStock or Inventory.reserve)")+  where+    segments = T.splitOn (T.pack ".") raw+    ok = not (null segments) && all isModulePart (init segments) && isVar (last segments)+    isVar t = case T.unpack t of+      c : cs -> (isLower c || c == '_') && all (\x -> isAlphaNum x || x == '_' || x == '\'') cs+      [] -> False+    isModulePart t = case T.unpack t of+      c : cs -> isUpper c && all (\x -> isAlphaNum x || x == '_' || x == '\'') cs+      [] -> False++orFail :: Either String a -> Q a+orFail = either (fail . ("scxml: " ++)) pure
+ test/Main.hs view
@@ -0,0 +1,294 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE QuasiQuotes #-}+module Main (main) where++import Control.Monad (unless, when)+import Control.Monad.Trans.State.Strict (StateT, gets, modify', runStateT)+import Data.IORef+import Data.Text (Text)+import qualified Data.Text as T+import System.Exit (exitFailure)++-- The library's entire public API.+import Scxml.Statechart (scxml)+import qualified Overrides+import qualified Reordered++-- An order process: compound states, a parallel state that completes via+-- SCXML's automatic done.state event, a choice state whose entry callback+-- decides where to go by raising an event, a self-transition used for+-- polling, and effects named in the SCXML. State ids and event names are+-- Haskell constructor names, used verbatim. The name attribute is optional+-- metadata and does not affect the generated names.+[scxml|+<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" name="order-v1" initial="Draft">+  <state id="Draft">+    <transition event="Submit" target="Validating"/>+    <!-- Several event names on one element is shorthand for several+         transitions with the same target. -->+    <transition event="Discard Abandon" target="Cancelled"/>+  </state>++  <state id="Validating">+    <onentry><script>validate</script></onentry>+    <transition event="Valid" target="Processing"/>+    <transition event="Invalid" target="Rejected"/>+  </state>++  <state id="Processing" initial="Authorizing">+    <onentry><script>reserveStock</script></onentry>+    <onexit><script>releaseStock</script></onexit>+    <state id="Authorizing">+      <onentry><script>checkPrepayment</script></onentry>+      <!-- Polling: re-enter this state to re-run its entry callback. -->+      <transition event="Poll" target="Authorizing"/>+      <transition event="PaymentAuthorized" target="Fulfilment"/>+    </state>+    <parallel id="Fulfilment">+      <state id="Shipping" initial="Packing">+        <state id="Packing">+          <transition event="Packed" target="Shipped"/>+        </state>+        <final id="Shipped"/>+      </state>+      <state id="Invoicing" initial="Unpaid">+        <state id="Unpaid">+          <transition event="Paid" target="Settled"/>+        </state>+        <final id="Settled"/>+      </state>+      <!-- Only Fulfilment may react to its own completion, and it can only+           reach a sibling, so it moves to a final state of Processing. That+           completes Processing, whose own done event carries it further out. -->+      <transition event="done.state.Fulfilment" target="Fulfilled"/>+    </parallel>+    <final id="Fulfilled"/>+    <!-- Leaving Processing is declared on Processing: transitions never+         cross levels, so these apply anywhere inside it. -->+    <transition event="PaymentDeclined" target="Rejected"/>+    <transition event="done.state.Processing" target="Completed"/>+    <transition event="Cancel" target="Cancelled"/>+  </state>++  <final id="Completed">+    <onentry><script>notifyCustomer</script></onentry>+  </final>+  <final id="Rejected"/>+  <final id="Cancelled"/>+</scxml>+|]++-- The quasiquote above generates:+--+--   data FsmState   = Draft | Validating | Processing Processing+--                   | Completed | Rejected | Cancelled+--   data Processing = Authorizing | Fulfilment Shipping Invoicing | Fulfilled+--   data Shipping   = Packing | Shipped+--   data Invoicing  = Unpaid | Settled+--   data FsmEvent   = Submit | Discard | Abandon | Valid | Invalid | Poll+--                   | PaymentAuthorized | Packed | Paid | DoneFulfilment+--                   | PaymentDeclined | DoneProcessing | Cancel+--                   | DoneShipping | DoneInvoicing+--   initiateStateMachine, notifyStateMachine   -- signatures below are ours+--   serializeStateMachine   :: FsmState -> [Text]+--   deserializeStateMachine :: [Text] -> Maybe FsmState++-- | The "datamodel": whatever the callbacks need lives in the monad.+data Shop = Shop+  { items    :: [Text]+  , prepaid  :: Bool+  , reserved :: Int+  , log_     :: [Text]+  }+  deriving (Show)++type M = StateT Shop IO++initiateStateMachine :: M FsmState+notifyStateMachine :: FsmState -> FsmEvent -> M FsmState++-- Callbacks named in the SCXML. Entry callbacks return m (Maybe FsmEvent),+-- raising an event with Just; exit callbacks return m (). All in one monad,+-- or it does not compile.++-- | A choice state's entry callback: decide by raising an event.+validate :: FsmState -> Maybe FsmEvent -> M (Maybe FsmEvent)+validate _ _ = do+  ok <- gets (not . null . items)+  pure (Just (if ok then Valid else Invalid))++-- | Poll something on entry; move on immediately if it is already settled.+-- Re-entered by the Poll self-transition, which is how polling replaces a+-- transition script.+checkPrepayment :: FsmState -> Maybe FsmEvent -> M (Maybe FsmEvent)+checkPrepayment _ _ = do+  say "checked prepayment"+  paid <- gets prepaid+  pure (if paid then Just PaymentAuthorized else Nothing)++-- Entry callbacks that raise nothing still say so, with pure Nothing.+reserveStock, notifyCustomer :: FsmState -> Maybe FsmEvent -> M (Maybe FsmEvent)+reserveStock _ _ = do+  n <- gets (length . items)+  modify' (\s -> s {reserved = n})+  say "reserved stock"+  pure Nothing+-- Entry callbacks see the state entered and the event that caused it.+notifyCustomer s ev = do+  say ("notified customer: " <> tshow s <> " after " <> maybe "start" tshow ev)+  pure Nothing++-- Exit callbacks see the state being left, and cannot raise.+releaseStock :: FsmState -> Maybe FsmEvent -> M ()+releaseStock s _ = modify' (\s' -> s' {reserved = 0}) >> say ("released stock leaving " <> tshow s)++say :: Text -> M ()+say t = modify' (\s -> s {log_ = log_ s ++ [t]})++tshow :: Show a => a -> Text+tshow = T.pack . show++-- | Deliver one event from a given state, discarding the datamodel.+stepFrom :: Shop -> FsmState -> FsmEvent -> IO FsmState+stepFrom shop s e = fst <$> runStateT (notifyStateMachine s e) shop++-- | Start the chart and feed events. Returns the final state and the datamodel.+runEvents :: Shop -> [FsmEvent] -> IO (FsmState, Shop)+runEvents shop evs = runStateT (initiateStateMachine >>= go evs) shop+  where+    go [] s = pure s+    go (e : rest) s = notifyStateMachine s e >>= go rest++shopWith :: [Text] -> Shop+shopWith is = Shop {items = is, prepaid = False, reserved = 0, log_ = []}++main :: IO ()+main = do+  failures <- newIORef (0 :: Int)+  let check :: (Eq a, Show a) => String -> a -> a -> IO ()+      check label expected actual =+        unless (expected == actual) $ do+          putStrLn ("FAIL " ++ label ++ "\n  expected: " ++ show expected ++ "\n  actual:   " ++ show actual)+          modifyIORef failures (+ 1)++  -- Happy path. Submit passes through Validating (its callback raises Valid),+  -- both regions reach final states, done.state.Fulfilment fires, and the+  -- callbacks run in SCXML order with the state and event they observe.+  (end, shop) <- runEvents (shopWith ["book"]) [Submit, PaymentAuthorized, Paid, Packed]+  check "happy path reaches Completed" Completed end+  -- Completion climbs one level at a time: Fulfilment finishing moves it to+  -- the final state Fulfilled, which completes Processing, whose own done+  -- event leaves it. All inside one call, so the caller sees only Completed.+  check "callbacks in order, with state and event"+    [ "reserved stock"+    , "checked prepayment"+    , "released stock leaving Processing Fulfilled"+    , "notified customer: Completed after DoneProcessing"+    ]+    (log_ shop)+  check "release resets the reservation" 0 (reserved shop)++  -- The choice state decides from the datamodel: no items, so Invalid is+  -- raised and nothing else runs.+  (end2, shopEmpty) <- runEvents (shopWith []) [Submit]+  check "empty order is rejected" Rejected end2+  check "no effects for a rejected order" [] (log_ shopEmpty)++  -- The choice state is transient: one Submit lands in Processing.+  (end2b, _) <- runEvents (shopWith ["book"]) [Submit]+  check "validating never rests" (Processing Authorizing) end2b++  -- Polling via a self-transition, the replacement for a transition script.+  -- Re-entering Authorizing re-runs its entry callback, which now sees the+  -- payment as settled and raises the event that moves the chart on.+  (end2c, shopPoll) <- runStateT+    ( do+        s0 <- initiateStateMachine+        s1 <- notifyStateMachine s0 Submit+        s2 <- notifyStateMachine s1 Poll+        modify' (\sh -> sh {prepaid = True}) -- the third party settles+        notifyStateMachine s2 Poll+    )+    (shopWith ["book"])+  check "polling moves on once the check succeeds" (Processing (Fulfilment Packing Unpaid)) end2c+  check "each poll re-runs the entry callback"+    ["reserved stock", "checked prepayment", "checked prepayment", "checked prepayment"]+    (log_ shopPoll)++  -- Moving a transition up a level widens where it applies: PaymentDeclined+  -- is declared on Processing, so it now rejects from inside Fulfilment too,+  -- where before (declared on Authorizing) it was ignored.+  (end2d, _) <- runEvents (shopWith ["book"]) [Submit, PaymentAuthorized, PaymentDeclined]+  check "a transition on the enclosing state applies anywhere inside it" Rejected end2d+  (end2e, _) <- runEvents (shopWith ["book"]) [Submit, PaymentDeclined]+  check "and still applies at the level it used to be on" Rejected end2e++  -- A transition on the parent applies anywhere inside it, and runs its onexit.+  (end3, shop3) <- runEvents (shopWith ["book"]) [Submit, PaymentAuthorized, Packed, Cancel]+  check "cancel from inside Fulfilment" Cancelled end3+  check "cancel releases stock"+    ["reserved stock", "checked prepayment", "released stock leaving Processing (Fulfilment Shipped Unpaid)"]+    (log_ shop3)++  -- An event with no transition in the current state is ignored: same state,+  -- no effects.+  (end4, shop4) <- runEvents (shopWith ["book"]) [Submit, Packed, Paid, Cancel, Cancel]+  check "unhandled events leave the state alone" Cancelled end4+  check "unhandled events run nothing"+    ["reserved stock", "checked prepayment", "released stock leaving Processing Authorizing"]+    (log_ shop4)++  -- An entry callback raising an event: processed before the step returns.+  (end5, shop5) <- runEvents (shopWith ["book"]) {prepaid = True} [Submit]+  check "raised event is processed in the same step" (Processing (Fulfilment Packing Unpaid)) end5+  check "raised event effects" ["reserved stock", "checked prepayment"] (log_ shop5)++  -- Regions are independent; one region completing does not complete the parallel.+  (end6, _) <- runEvents (shopWith ["book"]) [Submit, PaymentAuthorized, Packed]+  check "one region done" (Processing (Fulfilment Shipped Unpaid)) end6++  -- Behaviour reachable only through the generated functions, since the+  -- library exports nothing but the quasiquoter.+  (started, _) <- runStateT initiateStateMachine (shopWith ["book"])+  check "the chart starts in its initial state" Draft started+  doneFired <- stepFrom (shopWith ["book"]) (Processing (Fulfilment Shipped Unpaid)) Paid+  check "the last region completing fires the done event" Completed doneFired+  ignored <- stepFrom (shopWith ["book"]) Draft Paid+  check "an event with no transition here leaves the state alone" Draft ignored+  stayed <- stepFrom (shopWith ["book"]) (Processing Authorizing) Poll+  check "a self-transition re-enters without moving" (Processing Authorizing) stayed+  viaDiscard <- stepFrom (shopWith ["book"]) Draft Discard+  viaAbandon <- stepFrom (shopWith ["book"]) Draft Abandon+  check "several events on one transition all reach its target"+    (Cancelled, Cancelled) (viaDiscard, viaAbandon)+  check "all events, in document order"+    [ Submit, Discard, Abandon, Valid, Invalid, Poll, PaymentAuthorized, Packed+    , Paid, DoneFulfilment, PaymentDeclined, DoneProcessing, Cancel+    , DoneShipping, DoneInvoicing ]+    [minBound .. maxBound :: FsmEvent]++  -- Serialization. Show/Read round-trips exactly; the id list is the portable+  -- form, and rejects anything that is not a configuration of this chart.+  let deep = Processing (Fulfilment Shipped Unpaid)+  check "Read round-trips" deep (read (show deep))+  check "state ids" ["Fulfilment", "Invoicing", "Processing", "Shipped", "Shipping", "Unpaid"]+    (serializeStateMachine deep)+  check "id round-trip, nested" (Just deep) (deserializeStateMachine (serializeStateMachine deep))+  check "id round-trip, atomic" (Just Draft) (deserializeStateMachine (serializeStateMachine Draft))+  check "id order and duplicates do not matter" (Just deep)+    (deserializeStateMachine (reverse (serializeStateMachine deep) ++ ["Processing"]))+  check "an id list that merely starts valid is rejected" Nothing+    (deserializeStateMachine ["Draft", "Processing"])+  check "an incomplete configuration is rejected" Nothing+    (deserializeStateMachine ["Processing"])+  check "an unknown id is rejected" Nothing (deserializeStateMachine ["Archived"])+  check "an empty list is rejected" Nothing (deserializeStateMachine [])++  -- A second chart, in its own module since the generated names are fixed.+  reordered <- Reordered.spec+  overrides <- Overrides.spec+  mapM_ (\(label, expected, actual) -> check label expected actual) (reordered ++ overrides)++  n <- readIORef failures+  when (n > 0) exitFailure+  putStrLn "all checks passed"
+ test/Overrides.hs view
@@ -0,0 +1,58 @@+{-# LANGUAGE QuasiQuotes #-}+-- | Two behaviours nothing else covers: a transition on an enclosing state+-- acting as a default that an inner state overrides, and several entry+-- callbacks on one state each raising an event.+module Overrides where++import Control.Monad.Trans.State.Strict (StateT, modify', runStateT)+import Scxml.Statechart (scxml)++[scxml|+<scxml initial="Outer">+  <state id="Outer" initial="Inner">+    <state id="Inner">+      <!-- Overrides Outer's Poke while Inner is active. -->+      <transition event="Poke" target="Middle"/>+    </state>+    <state id="Middle">+      <onentry><script>noteEntry</script><script>raiseYes</script><script>raiseNo</script></onentry>+      <transition event="Yes" target="Yeah"/>+      <transition event="No" target="Nope"/>+    </state>+    <state id="Yeah"/>+    <state id="Nope"/>+    <!-- The default, taken wherever nothing inner handles Poke. -->+    <transition event="Poke" target="Away"/>+  </state>+  <state id="Away"/>+</scxml>+|]++initiateStateMachine :: StateT [String] IO FsmState+notifyStateMachine :: FsmState -> FsmEvent -> StateT [String] IO FsmState++noteEntry, raiseYes, raiseNo :: FsmState -> Maybe FsmEvent -> StateT [String] IO (Maybe FsmEvent)+noteEntry _ _ = modify' (++ ["entered Middle"]) >> pure Nothing+raiseYes _ _ = modify' (++ ["raising Yes"]) >> pure (Just Yes)+raiseNo _ _ = modify' (++ ["raising No"]) >> pure (Just No)++-- | Checks to run, as (label, expected, actual) triples.+spec :: IO [(String, String, String)]+spec = do+  -- Poke while Inner is active: the inner transition wins, so we do not leave+  -- Outer. Middle's three entry callbacks all run, and the first event raised+  -- decides where we land; the second arrives in Yeah, which ignores it.+  (afterPoke, lg) <- runStateT (initiateStateMachine >>= \s -> notifyStateMachine s Poke) []+  -- Poke again from Yeah, which has no Poke of its own, so Outer's applies.+  (afterAgain, _) <- runStateT (notifyStateMachine afterPoke Poke) []+  pure+    [ ("an inner transition overrides the enclosing default", show (Outer Yeah), show afterPoke)+    , ("every entry callback runs, in document order"+      , show ["entered Middle", "raising Yes", "raising No"]+      , show lg+      )+    , ("the enclosing default applies where nothing inner handles the event"+      , show Away+      , show afterAgain+      )+    ]
+ test/Reordered.hs view
@@ -0,0 +1,47 @@+{-# LANGUAGE QuasiQuotes #-}+-- | A chart whose initial states are deliberately not written first, to pin+-- what that does to the generated types. The model keeps a compound state's+-- children with the initial one first, so a compound state cannot name an+-- initial child that is not its own. That order shows up in the generated+-- constructors, and therefore in derived 'Ord'.+-- A chart module should not use an explicit export list, or it will get+-- unused-binding warnings for the generated functions it does not call.+module Reordered where++import Scxml.Statechart (scxml)++[scxml|+<scxml initial="Job">+  <final id="Done"/>+  <state id="Job" initial="Third">+    <state id="First"><transition event="Go" target="Third"/></state>+    <state id="Second"/>+    <state id="Third"><transition event="Back" target="First"/></state>+  </state>+</scxml>+|]++-- Generated, with the initial child first in each:+--+--   data FsmState = Job Job | Done+--   data Job      = Third | First | Second++initiateStateMachine :: IO FsmState+notifyStateMachine :: FsmState -> FsmEvent -> IO FsmState++-- | Checks to run, as (label, expected, actual) triples.+spec :: IO [(String, String, String)]+spec = do+  started <- initiateStateMachine+  back <- notifyStateMachine started Back+  forth <- notifyStateMachine back Go+  pure+    [ ("the initial child is entered even when written last", show (Job Third), show started)+    , ("transitions between siblings still work", show (Job First), show back)+    , ("and back again", show (Job Third), show forth)+    , -- Third is the initial child so it is the first constructor, which makes+      -- it compare less than the states written before it in the XML.+      ("Ord follows the initial-first constructor order", show GT, show (compare (Job First) (Job Third)))+    , ("Done sorts after Job, though written before it", show LT, show (compare (Job Second) Done))+    , ("serializing is unaffected by the reordering", show ["Job", "Third"], show (serializeStateMachine started))+    ]