packages feed

cleveland-0.3.2: src/Test/Cleveland/Internal/Exceptions/Annotated.hs

-- SPDX-FileCopyrightText: 2022 Oxhead Alpha
-- SPDX-License-Identifier: LicenseRef-MIT-OA

{-# OPTIONS_HADDOCK not-home #-}

-- | This module defines annotated exceptions and machinery to work with them.
module Test.Cleveland.Internal.Exceptions.Annotated
  ( module Test.Cleveland.Internal.Exceptions.Annotated
  ) where

import Data.Dependent.Map qualified as Map
import Data.GADT.Compare (GCompare(..), GEq(..), GOrdering(..))
import Data.Typeable (cast, eqT)
import Fmt (Buildable(..), Doc, build, pretty)
import Text.Show qualified as Show (Show(..))
import Type.Reflection (typeRep)

-- $setup
-- >>> import Test.Cleveland.Internal.Abstract
-- >>> import Test.Cleveland.Internal.Exceptions.CallStack

----------------------------------------------------------------------------
-- AnnotatedException
----------------------------------------------------------------------------

-- | 'SomeException' augmented with arbitrary annotations.
data AnnotatedException = AnnotatedException AnnotationMap SomeException
  deriving stock Show

instance Exception AnnotatedException where
  displayException (AnnotatedException annmap e) =
    pretty $ printAnnotations annmap (displayException e)

-- | Insert a new annotation into 'AnnotatedException'.
insertAnnEx'
  :: (Semigroup ann, ExceptionAnnotation ann)
  => ann -> AnnotatedException -> AnnotatedException
insertAnnEx' ann (AnnotatedException anns ex) = AnnotatedException (insertAnn ann anns) ex

-- | Insert a new annotation into 'SomeException'.
--
-- If it's already an 'AnnotatedException', simply adds a new annotation.
-- Otherwise, wraps the exception into 'AnnotatedException' first.
insertAnnEx
  :: (Semigroup ann, ExceptionAnnotation ann)
  => ann -> SomeException -> SomeException
insertAnnEx ann se = toException $! case fromException @AnnotatedException se of
  Just ex -> insertAnnEx' ann ex
  Nothing -> AnnotatedException (singletonAnn ann) se

-- | Remove an annotation of the given type from 'AnnotatedException'.
removeAnnEx' :: forall ann. ExceptionAnnotation ann => AnnotatedException -> AnnotatedException
removeAnnEx' (AnnotatedException anns ex) = AnnotatedException (removeAnn @ann anns) ex

-- | Remove an annotation of the given type from 'SomeException'. If it's not an
-- 'AnnotatedException', this is a no-op.
removeAnnEx :: forall ann. ExceptionAnnotation ann => SomeException -> SomeException
removeAnnEx se = case fromException @AnnotatedException se of
  Just ex -> toException $ removeAnnEx' @ann ex
  Nothing -> se

-- | Extract the given annotation type from 'AnnotatedException' if possible.
lookupAnnEx' :: forall ann. (ExceptionAnnotation ann) => AnnotatedException -> Maybe ann
lookupAnnEx' (AnnotatedException anns _) = lookupAnn @ann anns

-- | Extract the given annotation type from 'SomeException' if possible.
lookupAnnEx :: forall ann. (ExceptionAnnotation ann) => SomeException -> Maybe ann
lookupAnnEx se = lookupAnnEx' =<< fromException @AnnotatedException se

{- | Try to extract a given exception type, skipping over annotations. This
essentially makes 'AnnotatedException' transparent for this function.

This is intended to be used as the default implementation of 'fromException' for
exception types that are expected to be annotated, as to make
'AnnotatedException' transparent for 'catch', 'try', etc.

For example:

@
instance Exception GenericTestError where
  displayException = pretty
  fromException = fromPossiblyAnnotatedException
@

Now, when catching @GenericTestError@, it'll work even if wrapped in
'AnnotatedException':

>>> try @_ @GenericTestError (addCallStack $ throwM UnexpectedSuccess)
Left UnexpectedSuccess

When catching exceptions not explicitly set up like this, this trick
unfortunately won't work, so using this function explicitly is required:

>>> data ExceptionToCatch = ExceptionToCatch deriving stock Show
>>> instance Exception ExceptionToCatch
>>> action = addCallStack $ throwM ExceptionToCatch
>>> try @_ @ExceptionToCatch action
*** Exception: AnnotatedException ...
...ExceptionToCatch
>>> :{
action `catch` \(err :: SomeException) -> case fromPossiblyAnnotatedException err of
  Just (err' :: ExceptionToCatch) -> pass
  Nothing -> throwM err
:}
-}
fromPossiblyAnnotatedException :: Exception e => SomeException -> Maybe e
fromPossiblyAnnotatedException se =
  (do
    AnnotatedException _ e <- fromException se
    fromException e
  ) <|> fromExceptionDefault se
  where
    fromExceptionDefault (SomeException e) = cast e

----------------------------------------------------------------------------
-- AnnotationMap
----------------------------------------------------------------------------

data Key ann where
  Key :: ExceptionAnnotation a => Key a

instance GEq Key where
  geq (Key :: Key a) (Key :: Key b) = eqT @a @b

instance GCompare Key where
  gcompare (Key :: Key a) (Key :: Key b) =
    case compare (annotationPriority @a) (annotationPriority @b) of
      LT -> GLT
      GT -> GGT
      EQ -> gcompare (typeRep @a) (typeRep @b)

-- | A map from 'Key' to corresponding annotation.
newtype AnnotationMap = AnnotationMap { unAnnotationMap :: Map.DMap Key Identity }

instance Show AnnotationMap where
  show = Map.showTreeWith (\Key -> Show.show) True False . unAnnotationMap

-- | Print all annotations from an 'AnnotationMap', given the initial message.
--
-- Essentially right-fold using 'displayAnnotation'.
printAnnotations :: Buildable a => AnnotationMap -> a -> Doc
printAnnotations (AnnotationMap annmap) = flip (Map.foldrWithKey go) annmap . build
  where
    go :: Key t -> Identity t -> Doc -> Doc
    go Key (Identity a) acc = displayAnnotation a acc

-- | Try to find the given type @t@ in an 'AnnotationMap'.
lookupAnn :: ExceptionAnnotation t => AnnotationMap -> Maybe t
lookupAnn (AnnotationMap anns) = runIdentity <$> Map.lookup Key anns

-- | Construct a singleton 'AnnotationMap'.
singletonAnn :: ExceptionAnnotation t => t -> AnnotationMap
singletonAnn = AnnotationMap . Map.singleton Key . Identity

-- | Insert a new annotation into an 'AnnotationMap'. If an annotation of this
-- type already exists, merge the annotations using the 'Semigroup' instance.
insertAnn :: (Semigroup t, ExceptionAnnotation t) => t -> AnnotationMap -> AnnotationMap
insertAnn x (AnnotationMap anns) = AnnotationMap $ Map.insertWith (<>) Key (Identity x) anns

-- | Remove an annotation of a given type @t@ from 'AnnotationMap'.
removeAnn :: forall t. ExceptionAnnotation t => AnnotationMap -> AnnotationMap
removeAnn (AnnotationMap am) = AnnotationMap $ Map.delete (Key @t) am

----------------------------------------------------------------------------
-- ExceptionAnnotation
----------------------------------------------------------------------------

-- | Type class for exception annotations.
class (Show ann, Typeable ann) => ExceptionAnnotation ann where
  -- | Given an annotation and the error message (as 'Doc'), produce the
  -- text annotated with the annotation.
  displayAnnotation :: ann -> Doc -> Doc

  -- | Relative priority for sorting annotations. Annotations with higher
  -- priority will be applied first. Default is @0@.
  annotationPriority :: Int
  annotationPriority = 0

-- | Add an annotation to exceptions thrown by a monadic action.
annotateExceptions
  :: forall m a ann. (MonadCatch m, Semigroup ann, ExceptionAnnotation ann)
  => ann -> m a -> m a
annotateExceptions ann ma = ma `catch` (throwM . insertAnnEx ann)