packages feed

skeletest-0.3.5: src/Skeletest/Internal/Snapshot.hs

{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE NoFieldSelectors #-}

module Skeletest.Internal.Snapshot (
  -- * Running snapshot
  SnapshotContext (..),
  SnapshotResult (..),
  updateSnapshot,
  checkSnapshot,

  -- * Rendering
  SnapshotRenderer (..),
  defaultSnapshotRenderers,
  setSnapshotRenderers,
  getSnapshotRenderers,
  plainRenderer,
  renderWithShow,

  -- ** SnapshotFile
  SnapshotFile (..),
  SnapshotValue (..),
  decodeSnapshotFile,
  encodeSnapshotFile,
  normalizeSnapshotFile,

  -- * Infrastructure
  getAndIncSnapshotIndex,
  SnapshotUpdateFlag (..),
) where

import Control.Monad.IO.Class (MonadIO)
import Control.Monad.Trans.Except (runExceptT, throwE)
import Data.Aeson qualified as Aeson
import Data.Aeson.Encode.Pretty qualified as Aeson
import Data.Char (isAlpha, isPrint)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe, mapMaybe)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.IO qualified as Text
import Data.Text.Lazy qualified as TextL
import Data.Text.Lazy.Encoding qualified as TextL
import Data.Typeable (Typeable)
import Data.Typeable qualified as Typeable
import Data.Void (absurd)
import Debug.RecoverRTTI (anythingToString)
import Skeletest.Internal.CLI (FlagSpec (..), IsFlag (..))
import Skeletest.Internal.Error (SkeletestError (..), invariantViolation)
import Skeletest.Internal.Fixtures (
  Fixture (..),
  FixtureScope (..),
  getFixture,
  noCleanup,
  withCleanup,
 )
import Skeletest.Internal.Paths (readTestFile)
import Skeletest.Internal.TestInfo (TestInfo (..), getTestInfo)
import Skeletest.Internal.Utils.Map qualified as Map.Utils
import System.Directory (createDirectoryIfMissing)
import System.FilePath (replaceExtension, splitFileName, takeDirectory, (</>))
import System.IO.Error (isDoesNotExistError)
import System.IO.Unsafe (unsafePerformIO)
import UnliftIO.Exception (throwIO, try)
import UnliftIO.IORef (
  IORef,
  atomicModifyIORef',
  modifyIORef',
  newIORef,
  readIORef,
  writeIORef,
 )

{----- Infrastructure -----}

data SnapshotTestFixture = SnapshotTestFixture
  { snapshotIndexRef :: IORef Int
  }

instance Fixture SnapshotTestFixture where
  fixtureAction = do
    snapshotIndexRef <- newIORef 0
    pure . noCleanup $ SnapshotTestFixture{..}

getAndIncSnapshotIndex :: (MonadIO m) => m Int
getAndIncSnapshotIndex = do
  SnapshotTestFixture{snapshotIndexRef} <- getFixture
  atomicModifyIORef' snapshotIndexRef $ \i -> (i + 1, i)

data SnapshotFileFixture = SnapshotFileFixture
  { snapshotFileRef :: IORef (Maybe SnapshotFile)
  }

instance Fixture SnapshotFileFixture where
  fixtureScope = PerFileFixture
  fixtureAction = do
    TestInfo{file} <- getTestInfo
    let snapshotPath = getSnapshotPath file

    mSnapshotFile <-
      try (readTestFile snapshotPath) >>= \case
        Left e
          | isDoesNotExistError e -> pure Nothing
          | otherwise -> throwIO e
        Right contents ->
          case decodeSnapshotFile contents of
            Just snapshotFile -> pure $ Just snapshotFile
            Nothing -> throwIO $ SnapshotFileCorrupted snapshotPath
    let snapshotChanged newSnapshot = mSnapshotFile /= Just newSnapshot

    snapshotFileRef <- newIORef mSnapshotFile
    pure . withCleanup SnapshotFileFixture{..} $
      -- write snapshot back out when file is done
      readIORef snapshotFileRef >>= \case
        Just snapshotFile | snapshotChanged snapshotFile -> do
          createDirectoryIfMissing True (takeDirectory snapshotPath)
          Text.writeFile snapshotPath $ encodeSnapshotFile $ normalizeSnapshotFile snapshotFile
        _ -> pure ()

newtype SnapshotUpdateFlag = SnapshotUpdateFlag Bool

instance IsFlag SnapshotUpdateFlag where
  flagName = "update"
  flagShort = Just 'u'
  flagHelp = "Update snapshots"
  flagSpec = SwitchFlag SnapshotUpdateFlag

{----- Running snapshot -----}

data SnapshotContext = SnapshotContext
  { snapshotRenderers :: [SnapshotRenderer]
  , snapshotTestInfo :: TestInfo
  , snapshotIndex :: Int
  }

updateSnapshot :: (Typeable a, MonadIO m) => SnapshotContext -> a -> m ()
updateSnapshot snapshotContext testResult = do
  SnapshotFileFixture{snapshotFileRef} <- getFixture
  modifyIORef' snapshotFileRef (Just . setSnapshot . fromMaybe emptySnapshotFile)
 where
  SnapshotContext
    { snapshotRenderers = renderers
    , snapshotTestInfo = testInfo@TestInfo{file}
    , snapshotIndex
    } = snapshotContext

  emptySnapshotFile =
    SnapshotFile
      { testFile = Text.pack file
      , snapshots = Map.empty
      }

  testIdentifier = toTestIdentifier testInfo
  renderedTestResult = renderVal renderers testResult
  setSnapshot snapshotFile@SnapshotFile{snapshots} =
    let setForTest = Map.Utils.adjustNested (setAt snapshotIndex renderedTestResult) testIdentifier
     in snapshotFile{snapshots = setForTest snapshots}

  -- Set the given snapshot at the given index. If the index is too large,
  -- fill in with empty snapshots.
  --
  -- >>> setAt 3 "x" ["a"] == ["a", "", "", "x"]
  setAt i0 v =
    let go = \cases
          i [] -> replicate i emptySnapshotVal <> [v]
          0 (_ : xs) -> v : xs
          i (x : xs) -> x : go (i - 1) xs
     in if i0 < 0
          then invariantViolation $ "Got negative snapshot index: " <> show i0
          else go i0
  emptySnapshotVal = SnapshotValue{snapshotContent = "", snapshotLang = Nothing}

data SnapshotResult
  = SnapshotMissing
  | SnapshotMatches
  | SnapshotDiff
      { snapshotContent :: Text
      , renderedTestResult :: Text
      }
  deriving (Show, Eq)

checkSnapshot :: (Typeable a, MonadIO m) => SnapshotContext -> a -> m SnapshotResult
checkSnapshot snapshotContext testResult =
  fmap (either id absurd) . runExceptT $ do
    SnapshotFileFixture{snapshotFileRef} <- getFixture
    fileSnapshots <-
      readIORef snapshotFileRef >>= \case
        Nothing -> returnE SnapshotMissing
        Just SnapshotFile{snapshots} -> pure snapshots

    let snapshots = Map.Utils.findOrEmpty (toTestIdentifier testInfo) fileSnapshots
    snapshot <- maybe (returnE SnapshotMissing) pure $ safeIndex snapshots snapshotIndex

    let (snapshotContent, renderedTestResult) = (getContent snapshot, getContent renderedTestResultVal)
    returnE $
      if snapshotContent == renderedTestResult
        then SnapshotMatches
        else SnapshotDiff{snapshotContent, renderedTestResult}
 where
  SnapshotContext
    { snapshotRenderers = renderers
    , snapshotTestInfo = testInfo
    , snapshotIndex
    } = snapshotContext

  returnE = throwE
  renderedTestResultVal = renderVal renderers testResult

  safeIndex xs0 i0 =
    let go = \cases
          _ [] -> Nothing
          0 (x : _) -> Just x
          i (_ : xs) -> go (i - 1) xs
     in if i0 < 0 then Nothing else go i0 xs0

{----- Snapshot file -----}

data SnapshotFile = SnapshotFile
  { testFile :: Text
  , snapshots :: Map TestIdentifier [SnapshotValue]
  -- ^ full test identifier => snapshots
  -- e.g. ["group1", "group2", "returns val1 and val2"] => ["val1", "val2"]
  }
  deriving (Show, Eq)

data SnapshotValue = SnapshotValue
  { snapshotContent :: Text
  , snapshotLang :: Maybe Text
  }
  deriving (Show, Eq)

getContent :: SnapshotValue -> Text
getContent SnapshotValue{snapshotContent} = snapshotContent

type TestIdentifier = [Text]

getSnapshotPath :: FilePath -> FilePath
getSnapshotPath testFile = testDir </> "__snapshots__" </> snapshotFileName
 where
  (testDir, testFileName) = splitFileName testFile
  snapshotFileName = replaceExtension testFileName ".snap.md"

toTestIdentifier :: TestInfo -> TestIdentifier
toTestIdentifier TestInfo{contexts, name} = contexts <> [name]

decodeSnapshotFile :: Text -> Maybe SnapshotFile
decodeSnapshotFile = parseFile . Text.lines
 where
  parseFile = \case
    line : rest
      | Just testFile <- Text.stripPrefix "# " line -> do
          let snapshotFile =
                SnapshotFile
                  { testFile = Text.strip testFile
                  , snapshots = Map.empty
                  }
          parseSections snapshotFile Nothing rest
    _ -> Nothing

  parseSections ::
    SnapshotFile ->
    -- \^ The parsed snapshot file so far
    Maybe [Text] ->
    -- \^ The current test identifier, if one is set
    [Text] ->
    -- \^ The rest of the lines to process
    Maybe SnapshotFile
  parseSections snapshotFile@SnapshotFile{snapshots} mTest = \case
    [] -> pure snapshotFile
    line : rest
      -- ignore empty lines
      | "" <- Text.strip line -> parseSections snapshotFile mTest rest
      -- found a test section
      | Just sectionName <- Text.stripPrefix "## " line -> do
          let testIdentifier = map Text.strip $ Text.splitOn " / " sectionName
          let snapshotFile' = snapshotFile{snapshots = Map.insert testIdentifier [] snapshots}
          parseSections snapshotFile' (Just testIdentifier) rest
      -- found the beginning of a snapshot
      | Just lang <- (Text.stripPrefix "```" . Text.strip) line -> do
          testIdentifier <- mTest
          (snapshot, rest') <- parseSnapshot [] rest
          let
            snapshotVal =
              SnapshotValue
                { snapshotContent = snapshot
                , snapshotLang = if Text.null lang then Nothing else Just lang
                }
            snapshotFile' = snapshotFile{snapshots = Map.adjust (<> [snapshotVal]) testIdentifier snapshots}
          parseSections snapshotFile' mTest rest'
      -- anything else is invalid
      | otherwise -> Nothing

  parseSnapshot snapshot = \case
    [] -> Nothing
    line : rest
      | "```" <- Text.strip line -> pure (Text.unlines snapshot, rest)
      | otherwise -> parseSnapshot (snapshot <> [line]) rest

encodeSnapshotFile :: SnapshotFile -> Text
encodeSnapshotFile SnapshotFile{..} =
  Text.intercalate "\n" $
    h1 testFile : concatMap toSection (Map.toList snapshots)
 where
  toSection (testIdentifier, snaps) =
    h2 (Text.intercalate " / " testIdentifier) : map codeBlock snaps

  h1 s = "# " <> s <> "\n"
  h2 s = "## " <> s <> "\n"
  codeBlock SnapshotValue{..} =
    Text.concat
      [ "```" <> fromMaybe "" snapshotLang <> "\n"
      , snapshotContent
      , "```\n"
      ]

normalizeSnapshotFile :: SnapshotFile -> SnapshotFile
normalizeSnapshotFile file@SnapshotFile{snapshots} =
  file
    { snapshots = Map.fromList . map normalize . Map.toList $ snapshots
    }
 where
  normalize (testIdentifier, vals) =
    ( map (sanitizeNonPrint . sanitizeSlashes . Text.strip) testIdentifier
    , map normalizeSnapshotVal vals
    )

  sanitizeSlashes = Text.replace " /" " \\/"

  sanitizeNonPrint = Text.concatMap $ \case
    c | (not . isPrint) c -> Text.drop 1 . Text.dropEnd 1 . Text.pack . show $ c
    c -> Text.singleton c

{----- Renderers -----}

data SnapshotRenderer
  = forall a.
  (Typeable a) =>
  SnapshotRenderer
  { render :: a -> Text
  , snapshotLang :: Maybe Text
  }

plainRenderer :: (Typeable a) => (a -> Text) -> SnapshotRenderer
plainRenderer render =
  SnapshotRenderer
    { render
    , snapshotLang = Nothing
    }

renderWithShow :: forall a. (Typeable a, Show a) => SnapshotRenderer
renderWithShow = plainRenderer (Text.pack . show @a)

defaultSnapshotRenderers :: [SnapshotRenderer]
defaultSnapshotRenderers =
  [ plainRenderer @String Text.pack
  , plainRenderer @Text id
  , jsonRenderer
  ]
 where
  jsonRenderer =
    SnapshotRenderer
      { render = TextL.toStrict . TextL.decodeUtf8 . Aeson.encodePretty @Aeson.Value
      , snapshotLang = Just "json"
      }

renderVal :: (Typeable a) => [SnapshotRenderer] -> a -> SnapshotValue
renderVal renderers a =
  normalizeSnapshotVal $
    case mapMaybe tryRender renderers of
      [] ->
        SnapshotValue
          { snapshotContent = Text.pack $ anythingToString a
          , snapshotLang = Nothing
          }
      rendered : _ -> rendered
 where
  tryRender SnapshotRenderer{..} =
    let toValue v = SnapshotValue{snapshotContent = render v, snapshotLang}
     in toValue <$> Typeable.cast a

normalizeSnapshotVal :: SnapshotValue -> SnapshotValue
normalizeSnapshotVal SnapshotValue{..} =
  SnapshotValue
    { snapshotContent = normalizeTrailingNewlines snapshotContent
    , snapshotLang = collapse $ Text.filter isAlpha <$> snapshotLang
    }
 where
  collapse = \case
    Just "" -> Nothing
    m -> m

  -- Ensure there's exactly one trailing newline.
  normalizeTrailingNewlines s = Text.dropWhileEnd (== '\n') s <> "\n"

snapshotRenderersRef :: IORef [SnapshotRenderer]
snapshotRenderersRef = unsafePerformIO $ newIORef []
{-# NOINLINE snapshotRenderersRef #-}

setSnapshotRenderers :: [SnapshotRenderer] -> IO ()
setSnapshotRenderers = writeIORef snapshotRenderersRef

getSnapshotRenderers :: (MonadIO m) => m [SnapshotRenderer]
getSnapshotRenderers = readIORef snapshotRenderersRef