packages feed

hegel-0.1.0: src/Hegel/Generator.hs

{-# LANGUAGE GADTs #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}

-- | Core generator GADT and evaluation machinery.
--
-- Generators produce typed Haskell values by communicating with the Hegel
-- server over CBOR. The 'Generator' type is a GADT with six constructors
-- reflecting different generation strategies:
--
-- * 'Basic' holds a raw CBOR schema and a transform; the server generates the
--   value in one round-trip.
-- * 'Mapped' wraps a source generator and a transform function.
-- * 'FlatMapped' wraps a source and a function returning a generator.
-- * 'Filtered' wraps a source and a predicate (up to 'maxFilterAttempts' retries).
-- * 'CompositeList' uses the collection protocol for non-basic list elements.
-- * 'Composite' wraps a generation function inside a labelled span.
module Hegel.Generator
  ( -- * Generator type
    Generator (..)

    -- * Evaluation
  , draw

    -- * Combinators
  , gmap
  , flatMap
  , gfilter

    -- * Introspection
  , isBasic
  , asBasic

    -- * Span helpers
  , group
  , discardableGroup

    -- * Collection protocol
  , Collection (..)
  , newCollection
  , collectionMore
  , collectionReject

    -- * Span label constants
  , labelList
  , labelListElement
  , labelSet
  , labelSetElement
  , labelMap
  , labelMapEntry
  , labelTuple
  , labelOneOf
  , labelOptional
  , labelFixedDict
  , labelFlatMap
  , labelFilter
  , labelMapped
  , labelSampledFrom
  , labelEnumVariant

    -- * Constants
  , maxFilterAttempts
  ) where

import Codec.CBOR.Term (Term (..))
import Control.Exception (SomeException, catch, throwIO)
import Control.Monad (unless)
import Data.IORef

import Hegel.Client
  ( DataExhausted (..)
  , TestCase (..)
  , assume
  , generateFromSchema
  , startSpan
  , stopSpan
  )
import Hegel.Connection (RequestError (..), sendRequestAndWait)

-- ----------------------------------------------------------------------------
-- Span label constants
-- ----------------------------------------------------------------------------

-- | Span label for list generation.
labelList :: Int
labelList = 1

-- | Span label for individual list elements.
labelListElement :: Int
labelListElement = 2

-- | Span label for set generation.
labelSet :: Int
labelSet = 3

-- | Span label for individual set elements.
labelSetElement :: Int
labelSetElement = 4

-- | Span label for map (dictionary) generation.
labelMap :: Int
labelMap = 5

-- | Span label for individual map entries.
labelMapEntry :: Int
labelMapEntry = 6

-- | Span label for tuple generation.
labelTuple :: Int
labelTuple = 7

-- | Span label for one-of dispatch.
labelOneOf :: Int
labelOneOf = 8

-- | Span label for optional generation.
labelOptional :: Int
labelOptional = 9

-- | Span label for fixed-dict generation.
labelFixedDict :: Int
labelFixedDict = 10

-- | Span label for flat-map generation.
labelFlatMap :: Int
labelFlatMap = 11

-- | Span label for filter generation.
labelFilter :: Int
labelFilter = 12

-- | Span label for mapped generation.
labelMapped :: Int
labelMapped = 13

-- | Span label for sampled-from generation.
labelSampledFrom :: Int
labelSampledFrom = 14

-- | Span label for enum variant generation.
labelEnumVariant :: Int
labelEnumVariant = 15

-- | Maximum number of filter attempts before calling @assume False@.
maxFilterAttempts :: Int
maxFilterAttempts = 3

-- ----------------------------------------------------------------------------
-- Generator GADT
-- ----------------------------------------------------------------------------

-- | A generator that produces values of type @a@.
--
-- The GADT constructors capture different generation strategies. 'Basic'
-- generators carry a CBOR schema sent to the server; the server returns a raw
-- 'Term' that is decoded by the transform. Other constructors compose
-- generators with client-side logic.
data Generator a where
  -- | A server-side generator with a CBOR schema and a client-side transform.
  Basic :: Term -> (Term -> a) -> Generator a
  -- | A mapped generator: apply @f@ to the result of the source.
  Mapped :: Generator b -> (b -> a) -> Generator a
  -- | A dependent generator: generate from source, then use the result to
  -- pick a second generator.
  FlatMapped :: Generator b -> (b -> Generator a) -> Generator a
  -- | A filtered generator: retry the source until the predicate holds.
  Filtered :: Generator a -> (a -> Bool) -> Generator a
  -- | A composite list generator using the collection protocol.
  CompositeList :: Generator a -> Int -> Maybe Int -> Generator [a]
  -- | A composite generator that runs an arbitrary IO action inside a
  -- labelled span.
  Composite :: Int -> (TestCase -> IO a) -> Generator a

instance Functor Generator where
  fmap f (Basic s t) = Basic s (f . t)  -- preserves schema
  fmap f other       = Mapped other f

-- ----------------------------------------------------------------------------
-- Span helpers
-- ----------------------------------------------------------------------------

-- | Run an IO action inside a span with the given label.
-- The span is stopped with @discard=False@ regardless of whether the action
-- throws.
group :: Int -> TestCase -> IO a -> IO a
group label tc action = do
  startSpan label tc
  result <- action `catch` \(e :: SomeException) -> do
    stopSpan False tc
    throwIO e
  stopSpan False tc
  return result

-- | Run an IO action inside a span with the given label. If the action
-- throws, the span is stopped with @discard=True@; otherwise @discard=False@.
discardableGroup :: Int -> TestCase -> IO a -> IO a
discardableGroup label tc action = do
  startSpan label tc
  result <- action `catch` \(e :: SomeException) -> do
    stopSpan True tc
    throwIO e
  stopSpan False tc
  return result

-- ----------------------------------------------------------------------------
-- Collection protocol
-- ----------------------------------------------------------------------------

-- | A server-side collection handle for generating variable-length sequences.
--
-- Collections communicate with the server to decide when to stop generating
-- elements. The 'collFinished' flag short-circuits subsequent 'collectionMore'
-- calls once the server signals completion.
data Collection = Collection
  { collFinished   :: !(IORef Bool)
  , collServerName :: !(IORef (Maybe Term))
  , collMinSize    :: !Int
  , collMaxSize    :: !(Maybe Int)
  }

-- | Create a new collection handle.
newCollection :: Int -> Maybe Int -> TestCase -> IO Collection
newCollection minSize maxSize _tc = do
  finished   <- newIORef False
  serverName <- newIORef Nothing
  return Collection
    { collFinished   = finished
    , collServerName = serverName
    , collMinSize    = minSize
    , collMaxSize    = maxSize
    }

-- | Send a command on the test case's data channel. Handles StopTest.
sendCommand :: TestCase -> Term -> IO Term
sendCommand tc msg = do
  aborted <- readIORef (tcTestAborted tc)
  if aborted
    then throwIO DataExhausted
    else sendRequestAndWait (tcChannel tc) msg
           `catch` \(e :: RequestError) ->
             if reqErrorType e == "StopTest"
               then do
                 writeIORef (tcTestAborted tc) True
                 throwIO DataExhausted
               else throwIO e

-- | Lazily initialise the server-side collection and return its handle.
getServerName :: Collection -> TestCase -> IO Term
getServerName coll tc = do
  mName <- readIORef (collServerName coll)
  case mName of
    Just name -> return name
    Nothing -> do
      let maxSizeVal = maybe TNull TInt (collMaxSize coll)
      let cmd = TMap
            [ (TString "command",  TString "new_collection")
            , (TString "min_size", TInt (collMinSize coll))
            , (TString "max_size", maxSizeVal)
            ]
      result <- sendCommand tc cmd
      writeIORef (collServerName coll) (Just result)
      return result

-- | Query the server for whether more elements should be generated.
-- Once this returns 'False', subsequent calls return 'False' immediately.
collectionMore :: Collection -> TestCase -> IO Bool
collectionMore coll tc = do
  done <- readIORef (collFinished coll)
  if done
    then return False
    else do
      serverName <- getServerName coll tc
      let cmd = TMap
            [ (TString "command",    TString "collection_more")
            , (TString "collection", serverName)
            ]
      result <- sendCommand tc cmd
      let more = case result of
            TBool b -> b
            _       -> error "collectionMore: expected boolean from server"
      unless more $ writeIORef (collFinished coll) True
      return more

-- | Reject the last element of the collection. No-op if already finished.
collectionReject :: Collection -> TestCase -> IO ()
collectionReject coll tc = do
  done <- readIORef (collFinished coll)
  if done
    then return ()
    else do
      serverName <- getServerName coll tc
      let cmd = TMap
            [ (TString "command",    TString "collection_reject")
            , (TString "collection", serverName)
            ]
      _ <- sendCommand tc cmd
      return ()

-- ----------------------------------------------------------------------------
-- Draw (evaluation)
-- ----------------------------------------------------------------------------

-- | Produce a typed value from a generator using the given test case.
draw :: TestCase -> Generator a -> IO a
draw tc gen = case gen of
  Basic schema transform -> do
    raw <- generateFromSchema schema tc
    return (transform raw)

  Mapped source f ->
    group labelMapped tc $ do
      v <- draw tc source
      return (f v)

  FlatMapped source f ->
    discardableGroup labelFlatMap tc $ do
      v <- draw tc source
      draw tc (f v)

  Filtered source predicate ->
    let attempt i
          | i > maxFilterAttempts = do { assume tc False; error "unreachable" }
          | otherwise = do
              startSpan labelFilter tc
              v <- draw tc source
              if predicate v
                then do
                  stopSpan False tc
                  return v
                else do
                  stopSpan True tc
                  attempt (i + 1)
    in attempt 1

  CompositeList elements minSize maxSize ->
    group labelList tc $ do
      coll <- newCollection minSize maxSize tc
      let collect acc = do
            more <- collectionMore coll tc
            if more
              then do
                v <- draw tc elements
                collect (v : acc)
              else return (reverse acc)
      collect []

  Composite label generateFn ->
    group label tc (generateFn tc)

-- ----------------------------------------------------------------------------
-- Combinator functions
-- ----------------------------------------------------------------------------

-- | Map a function over a generator's output.
--
-- When the generator is 'Basic', the schema is preserved and transforms are
-- composed. Otherwise a 'Mapped' wrapper is created.
gmap :: (a -> b) -> Generator a -> Generator b
gmap f (Basic schema transform) = Basic schema (f . transform)
gmap f other                    = Mapped other f

-- | Create a dependent generator. The function receives the generated value
-- and returns a new generator whose output is the final result.
flatMap :: (a -> Generator b) -> Generator a -> Generator b
flatMap f gen = FlatMapped gen f

-- | Filter a generator's output with a predicate. Retries up to
-- 'maxFilterAttempts' times; if all attempts fail, @assume False@ is called.
gfilter :: (a -> Bool) -> Generator a -> Generator a
gfilter predicate gen = Filtered gen predicate

-- ----------------------------------------------------------------------------
-- Introspection
-- ----------------------------------------------------------------------------

-- | Returns 'True' if the generator is a 'Basic' generator.
isBasic :: Generator a -> Bool
isBasic (Basic _ _) = True
isBasic _           = False

-- | Returns the schema and transform of a 'Basic' generator, or 'Nothing'.
asBasic :: Generator a -> Maybe (Term, Term -> a)
asBasic (Basic schema transform) = Just (schema, transform)
asBasic _                        = Nothing