packages feed

rzk-0.10.0: test/Rzk/TypeCheckSpec.hs

{-# OPTIONS_GHC -Wno-partial-fields #-}
{-# LANGUAGE LambdaCase        #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards   #-}
module Rzk.TypeCheckSpec (spec) where

import           Control.Monad            (forM, unless)
import           Data.Aeson               (FromJSON, withObject, (.:), (.:?))
import           Data.Bifunctor           (first)
import           Data.List                (isInfixOf, isPrefixOf)
import qualified Data.Text                as T
import qualified Data.Text.IO             as T
import qualified Data.Yaml                as Yaml
import           System.Directory         (doesFileExist)
import           System.Environment       (lookupEnv)
import           System.FilePath          (dropExtension, (</>), takeDirectory,
                                           takeFileName)
import           System.FilePath.Glob     (compile, globDir1)

import qualified Language.Rzk.Syntax        as Rzk
import           Rzk.Diagnostic           (locationOfTypeError,
                                           typeErrorTagInScopedContext)
import           Rzk.TypeCheck            (Checked, Context (..),
                                           LocationInfo (..),
                                           OutputDirection (..),
                                           TypeErrorInScopedContext,
                                           Verbosity (..), checkedErrors,
                                           checkedModules, emptyContext,
                                           ppTypeErrorInScopedContext)

import           Test.Hspec

data Expect = Expect
  { expectStatus          :: String
  , expectErrorTag        :: Maybe String
  , expectMessageContains :: Maybe [String]
  , expectLine            :: Maybe Int
  , expectRegressionFor   :: Maybe [String]
  , expectModules         :: Maybe [FilePath]
  , expectApi             :: Maybe String
  } deriving (Show, Eq)

instance FromJSON Expect where
  parseJSON = withObject "Expect" $ \o -> Expect
    <$> o .:  "status"
    <*> o .:? "error_tag"
    <*> o .:? "message_contains"
    <*> o .:? "line"
    <*> o .:? "regression_for"
    <*> o .:? "modules"
    <*> o .:? "api"

-- | The line an error was raised on.
--
-- An error carries the context it was raised in, so there are no binder layers to
-- peel: the old representation nested the error one Inc deeper at every binder, and
-- this had to unsafeCoerce its way back out.
errorLine :: TypeErrorInScopedContext -> Maybe Int
errorLine err = locationOfTypeError err >>= locationLine

-- | The constructor name of a type error, used to match @error_tag@ in fixtures.
-- This is the library's own tag (also used for diagnostic codes), so the two cannot
-- drift apart.
typeErrorConstructorName :: TypeErrorInScopedContext -> String
typeErrorConstructorName = typeErrorTagInScopedContext

casesRoot :: FilePath
casesRoot = "test/typecheck/cases"

-- | When running tests from a different cwd (e.g. some CI), set @RZK_TEST_ROOT@ to the @rzk/@ package directory.
resolveRoot :: IO FilePath
resolveRoot = do
  m <- lookupEnv "RZK_TEST_ROOT"
  case m of
    Just root | not (null root) -> pure root
    _                           -> pure "."

globExpectPaths :: FilePath -> IO [FilePath]
globExpectPaths root =
  globDir1 (compile "**/*.expect.yaml") (root </> casesRoot)

globDirExpectPaths :: FilePath -> IO [FilePath]
globDirExpectPaths root =
  globDir1 (compile "**/expect.yaml") (root </> casesRoot)

readExpect :: FilePath -> IO (Either String Expect)
readExpect path = do
  e <- Yaml.decodeFileEither path
  pure $ first show e

loadModules :: [(FilePath, FilePath)] -> IO (Either String [(FilePath, Rzk.Module)])
loadModules [] = pure (Right [])
loadModules ((relPath, absPath) : rest) = do
  txt <- T.readFile absPath
  case Rzk.parseModule txt of
    Left err -> pure $ Left (T.unpack err)
    Right m  -> fmap (fmap ((relPath, m) :)) $ loadModules rest

-- | Run the checker with @verbosity = Silent@ so @traceTypeCheck Normal@ does not
-- clutter @stack test@ output (the CLI keeps the default @Normal@).
silently :: Context n -> Context n
silently ctx = ctx { ctxVerbosity = Silent }

-- | Strict: the first error stops the run and is returned.
runStrict :: [(FilePath, Rzk.Module)] -> Either TypeErrorInScopedContext Checked
runStrict ms = case checkedModules ms (silently emptyContext) of
  Left err -> Left err
  Right (checked, _holes) -> case checkedErrors checked of
    err : _ -> Left err
    []      -> Right checked

-- | Collect: every error is returned, and the run continues past them.
runCollect :: [(FilePath, Rzk.Module)] -> Either TypeErrorInScopedContext Checked
runCollect ms = fst <$> checkedModules ms (silently emptyContext)

assertExpect :: String -> Expect -> Either TypeErrorInScopedContext Checked -> IO ()
assertExpect _label Expect{..} (Right _) | expectStatus /= "ok" =
  expectationFailure "expected type error (status: error), but typechecking succeeded"
assertExpect label Expect{..} (Left err) | expectStatus == "ok" =
  expectationFailure $ "unexpected type error in " <> label <> ":\n"
    <> ppTypeErrorInScopedContext BottomUp err
assertExpect label Expect{..} (Left err) | expectStatus == "error" = do
  let tag = typeErrorConstructorName err
  case expectErrorTag of
    Nothing -> expectationFailure "expect.yaml missing error_tag for status: error"
    Just want | want /= tag ->
      expectationFailure $ "wrong error constructor in " <> label <> ": wanted "
        <> want <> ", got " <> tag <> "\nFull message:\n"
        <> ppTypeErrorInScopedContext BottomUp err
    Just _ -> pure ()
  case expectMessageContains of
    Nothing -> pure ()
    Just subs -> do
      let msg = ppTypeErrorInScopedContext BottomUp err
      mapM_ (\s -> unless (s `isInfixOf` msg) $
        expectationFailure $ "in " <> label <> ", message missing substring "
          <> show s <> ":\n" <> msg) subs
  case expectLine of
    Nothing -> pure ()
    Just ln -> case errorLine err of
      Nothing -> expectationFailure $ "in " <> label <> ", expected line "
        <> show ln <> " but error has no line"
      Just got | got /= ln ->
        expectationFailure $ "in " <> label <> ", expected line " <> show ln
          <> " got " <> show got
      Just _ -> pure ()
assertExpect _label Expect{expectStatus = "ok"} (Right _) = pure ()
assertExpect label Expect{expectStatus = st} _ =
  expectationFailure $ "in " <> label <> ", unknown status " <> show st

assertExpectCollect :: String -> Expect -> Either TypeErrorInScopedContext Checked -> IO ()
assertExpectCollect _label _ (Left err) =
  expectationFailure $ "unexpected fatal error: " <> ppTypeErrorInScopedContext BottomUp err
assertExpectCollect label Expect{..} (Right checked) = case checkedErrors checked of
 errs -> case expectStatus of
  "ok" | not (null errs) ->
    expectationFailure $ "in " <> label <> ", expected ok but got errors: " <> show (length errs)
  "ok" -> pure ()
  "error" -> case errs of
    [] ->
      expectationFailure $ "in " <> label <> ", expected type errors but got none"
    err : _ -> do
      let tag = typeErrorConstructorName err
      case expectErrorTag of
        Nothing -> expectationFailure "expect.yaml missing error_tag for status: error"
        Just want | want /= tag ->
          expectationFailure $ "wrong error constructor in " <> label <> ": wanted "
            <> want <> ", got " <> tag
        Just _ -> pure ()
      case expectMessageContains of
        Nothing -> pure ()
        Just subs -> do
          let msg = ppTypeErrorInScopedContext BottomUp err
          mapM_ (\s -> unless (s `isInfixOf` msg) $
            expectationFailure $ "in " <> label <> ", message missing substring "
              <> show s) subs
      case expectLine of
        Nothing -> pure ()
        Just ln -> case errorLine err of
          Nothing -> expectationFailure "expected line number on error"
          Just got | got /= ln ->
            expectationFailure $ "line mismatch: wanted " <> show ln <> " got " <> show got
          Just _ -> pure ()
  st ->
    expectationFailure $ "in " <> label <> ", unknown status " <> show st

data CaseKind
  = PairedCase
      { pcExpectPath :: FilePath
      , pcRzkPath    :: FilePath
      , pcRelTitle   :: String
      }
  | DirCase
      { dcDir        :: FilePath
      , dcExpectPath :: FilePath
      , dcRelTitle   :: String
      }

relTitleFrom :: FilePath -> FilePath -> FilePath
relTitleFrom root absP
  | root == "."                 = absP
  | root `isPrefixOf` absP      = drop (length root + 1) absP
  | ("./" <> root) `isPrefixOf` absP = drop (length root + 3) absP
  | otherwise                   = absP

discoverCases :: FilePath -> IO [CaseKind]
discoverCases root = do
  paired <- globExpectPaths root
  let paired' = [ p | p <- paired, takeFileName p /= "expect.yaml" ]
  pairedKinds <- forM paired' $ \absExpect -> do
    let dir      = takeDirectory absExpect
        fname    = takeFileName absExpect
        baseName = dropExtension (dropExtension fname)
        absRzk   = dir </> baseName <> ".rzk"
        absRzkMd = dir </> baseName <> ".rzk.md"
    absChosen <- do
      rzkOk <- doesFileExist absRzk
      if rzkOk
        then pure absRzk
        else do
          mdOk <- doesFileExist absRzkMd
          if mdOk
            then pure absRzkMd
            else ioError $ userError $
              "Missing twin .rzk or .rzk.md for " <> absExpect
    pure PairedCase
      { pcExpectPath = absExpect
      , pcRzkPath    = absChosen
      , pcRelTitle   = relTitleFrom root absExpect
      }
  dirExpects <- globDirExpectPaths root
  dirKinds <- forM dirExpects $ \absExpect -> do
    let dir = takeDirectory absExpect
    pure DirCase
      { dcDir        = dir
      , dcExpectPath = absExpect
      , dcRelTitle   = relTitleFrom root absExpect
      }
  pure (pairedKinds ++ dirKinds)

runCase :: CaseKind -> IO ()
runCase PairedCase{..} = do
  e <- readExpect pcExpectPath
  expect <- case e of
    Left err -> expectationFailure ("YAML " <> pcExpectPath <> ": " <> err) >> undefined
    Right x  -> pure x
  txt <- T.readFile pcRzkPath
  root <- resolveRoot
  case Rzk.parseModule txt of
    Left perr -> expectationFailure $
      "parse failure in " <> relTitleFrom root pcRzkPath <> ":\n" <> T.unpack perr
    Right m   -> do
      let relModPath = relTitleFrom root pcRzkPath
          mods       = [(relModPath, m)]
      case expectApi expect of
        Just "collect" -> assertExpectCollect pcRelTitle expect (runCollect mods)
        _              -> assertExpect pcRelTitle expect (runStrict mods)
runCase DirCase{..} = do
  e <- readExpect dcExpectPath
  expect <- case e of
    Left err -> expectationFailure ("YAML " <> dcExpectPath <> ": " <> err) >> undefined
    Right x  -> pure x
  root <- resolveRoot
  modRelAbs <- case expectModules expect of
    Nothing -> do
      let inp = dcDir </> "input.rzk"
      ok <- doesFileExist inp
      unless ok $ ioError $ userError $
        "Directory case " <> dcRelTitle <> ": add modules: [...] in expect.yaml or input.rzk"
      pure [(relTitleFrom root inp, inp)]
    Just names -> pure [(relTitleFrom root (dcDir </> n), dcDir </> n) | n <- names]
  modsE <- loadModules modRelAbs
  mods <- either (\msg -> expectationFailure ("load/parse in " <> dcRelTitle <> ": " <> msg) >> undefined) pure modsE
  case expectApi expect of
    Just "collect" -> assertExpectCollect dcRelTitle expect (runCollect mods)
    _              -> assertExpect dcRelTitle expect (runStrict mods)

spec :: Spec
spec = describe "Typecheck fixtures" $ do
  it "discovers at least one case" $ do
    root <- resolveRoot
    ks <- discoverCases root
    (length ks > 0) `shouldBe` True
  describe "cases" $ do
    root <- runIO resolveRoot
    ks <- runIO (discoverCases root)
    mapM_ (\k -> it (caseTitle k) (runCase k)) ks

caseTitle :: CaseKind -> String
caseTitle = \case
  PairedCase{..} -> pcRelTitle
  DirCase{..}    -> dcRelTitle