packages feed

aihc-cpp-2.0.0.0: test/Test/Progress.hs

{-# LANGUAGE OverloadedStrings #-}

module Test.Progress
  ( CaseMeta (..),
    Outcome (..),
    evaluateCase,
    loadManifest,
    progressSummary,
  )
where

import Aihc.Cpp (Config (..), Diagnostic (..), IncludeRequest (..), Result (..), Severity (..), Step (..), defaultConfig, preprocess)
import qualified Control.Exception as E
import qualified Data.ByteString as BS
import Data.Char (isDigit, isSpace)
import Data.List (dropWhileEnd)
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Text.Encoding.Error as TEE
import qualified Data.Text.IO as TIO
import GHC.IO.Handle (hDuplicate, hDuplicateTo)
import Language.Preprocessor.Cpphs (BoolOptions (..), CpphsOptions (..), defaultCpphsOptions, runCpphs)
import System.Directory (doesFileExist, getTemporaryDirectory, removeFile)
import System.FilePath (takeDirectory, (</>))
import System.IO (IOMode (ReadMode), hClose, hFlush, openTempFile, stderr, withFile)

data Expected = ExpectPass | ExpectXFail deriving (Eq, Show)

data Outcome = OutcomePass | OutcomeXFail | OutcomeXPass | OutcomeFail deriving (Eq, Show)

data CaseMeta = CaseMeta
  { caseId :: !String,
    caseCategory :: !String,
    casePath :: !FilePath,
    caseExpected :: !Expected,
    caseReason :: !String
  }
  deriving (Eq, Show)

fixtureRoot :: FilePath
fixtureRoot = "test/Test/Fixtures/progress"

manifestPath :: FilePath
manifestPath = fixtureRoot </> "manifest.tsv"

progressSummary :: [(CaseMeta, Outcome, String)] -> (Int, Int, Int, Int)
progressSummary outcomes =
  ( count OutcomePass,
    count OutcomeXFail,
    count OutcomeXPass,
    count OutcomeFail
  )
  where
    count wanted = length [() | (_, out, _) <- outcomes, out == wanted]

evaluateCase :: CaseMeta -> IO (CaseMeta, Outcome, String)
evaluateCase meta = do
  let sourcePath = fixtureRoot </> casePath meta
  source <- BS.readFile sourcePath
  ours <- runOurs sourcePath source
  oracle <- runOracle sourcePath
  let (outcome, details) = classify (caseExpected meta) ours oracle
  pure (meta, outcome, details)

classify :: Expected -> Either String Text -> Either String Text -> (Outcome, String)
classify expected ours oracle =
  case expected of
    ExpectPass ->
      case (ours, oracle) of
        (Right oursOut, Right oracleOut) ->
          case compareLocatedOutput oursOut oracleOut of
            Nothing -> (OutcomePass, "")
            Just details -> (OutcomeFail, "preprocessed output differs from cpphs oracle: " <> details)
        (Left _, Left _) -> (OutcomePass, "")
        (Left oursErr, _) -> (OutcomeFail, "ours failed: " <> oursErr)
        (_, Left oracleErr) -> (OutcomeFail, "oracle failed: " <> oracleErr)
    ExpectXFail ->
      case (ours, oracle) of
        (Right oursOut, Right oracleOut) ->
          case compareLocatedOutput oursOut oracleOut of
            Nothing -> (OutcomeXPass, "expected xfail but now matches cpphs")
            Just _ -> (OutcomeXFail, "")
        (Left _, Left _) -> (OutcomeXPass, "expected xfail but now matches cpphs")
        _ -> (OutcomeXFail, "")

data LocatedLine = LocatedLine
  { locatedLineNo :: !Int,
    locatedFile :: !FilePath,
    locatedText :: !Text
  }
  deriving (Eq, Show)

compareLocatedOutput :: Text -> Text -> Maybe String
compareLocatedOutput oursOut oracleOut =
  firstDifference (toLocatedLines oursOut) (toLocatedLines oracleOut)

firstDifference :: [LocatedLine] -> [LocatedLine] -> Maybe String
firstDifference = go (1 :: Int)
  where
    go :: Int -> [LocatedLine] -> [LocatedLine] -> Maybe String
    go _ [] [] = Nothing
    go n (o : os) (r : rs)
      | o == r = go (n + 1) os rs
      | otherwise =
          Just
            ( "first mismatch at output record "
                <> show n
                <> ": ours="
                <> showLocated o
                <> ", oracle="
                <> showLocated r
            )
    go n [] (r : _) =
      Just
        ( "ours ended early at output record "
            <> show n
            <> ", oracle has "
            <> showLocated r
        )
    go n (o : _) [] =
      Just
        ( "oracle ended early at output record "
            <> show n
            <> ", ours has "
            <> showLocated o
        )

showLocated :: LocatedLine -> String
showLocated (LocatedLine ln fp txt) =
  "(" <> show ln <> ", " <> show fp <> ", " <> show (T.unpack txt) <> ")"

toLocatedLines :: Text -> [LocatedLine]
toLocatedLines = go 1 "<unknown>" . T.lines
  where
    go _ _ [] = []
    go lineNo filePath (line : rest) =
      case parseLinePragma line of
        Just (nextLineNo, mFilePath) ->
          let filePath' = fromMaybe filePath mFilePath
           in go nextLineNo filePath' rest
        Nothing ->
          LocatedLine lineNo filePath line : go (lineNo + 1) filePath rest

parseLinePragma :: Text -> Maybe (Int, Maybe FilePath)
parseLinePragma raw =
  let line = T.strip raw
   in case T.stripPrefix "#line" line of
        Nothing -> Nothing
        Just rest0 ->
          let rest1 = T.stripStart rest0
              (lineNoTxt, rest2) = T.span isDigit rest1
           in if T.null lineNoTxt
                then Nothing
                else case reads (T.unpack lineNoTxt) of
                  [(lineNo, "")] ->
                    let rest3 = T.stripStart rest2
                     in if T.null rest3
                          then Just (lineNo, Nothing)
                          else case T.uncons rest3 of
                            Just ('"', quoted) ->
                              let (filePath, suffix) = T.breakOn "\"" quoted
                               in if T.null suffix
                                    then Nothing
                                    else Just (lineNo, Just (T.unpack filePath))
                            _ -> Nothing
                  _ -> Nothing

runOurs :: FilePath -> BS.ByteString -> IO (Either String Text)
runOurs sourcePath source = do
  result <- drive (preprocess defaultConfig {configInputFile = sourcePath} source)
  let errors = [diagMessage d | d <- resultDiagnostics result, diagSeverity d == Error]
  case errors of
    -- The oracle is 'String'-based, so compare decoded text. Fixtures are
    -- all ASCII; byte-exactness of the output is covered in Spec.hs.
    [] -> pure (Right (TE.decodeUtf8With TEE.lenientDecode (resultOutput result)))
    (msg : _) -> pure (Left (T.unpack msg))
  where
    drive (Done result) = pure result
    drive (NeedInclude req k) = do
      let includeAbsPath = resolveIncludePath sourcePath req
      exists <- doesFileExist includeAbsPath
      content <- if exists then Just <$> BS.readFile includeAbsPath else pure Nothing
      drive (k content)

resolveIncludePath :: FilePath -> IncludeRequest -> FilePath
resolveIncludePath rootPath req =
  includeBaseDir </> includePath req
  where
    includeFromDir = takeDirectory (includeFrom req)
    includeBaseDir =
      if null includeFromDir
        then takeDirectory rootPath
        else includeFromDir

runOracle :: FilePath -> IO (Either String Text)
runOracle sourcePath = do
  source <- TIO.readFile sourcePath
  let cpphsOptions =
        defaultCpphsOptions
          { boolopts =
              (boolopts defaultCpphsOptions)
                { stripC89 = True,
                  warnings = False
                }
          }
  (oracleOut, capturedStderr) <-
    captureStderrText
      ( ( E.try $ do
            out <- runCpphs cpphsOptions sourcePath (T.unpack source)
            _ <- E.evaluate (length out)
            pure out
        ) ::
          IO (Either E.SomeException String)
      )
  pure $
    case oracleOut of
      Right out -> Right (T.pack out)
      Left err ->
        Left
          ( "cpphs failed: "
              <> show err
              <> stderrSuffix capturedStderr
          )

captureStderrText :: IO a -> IO (a, Text)
captureStderrText action = do
  tempDir <- getTemporaryDirectory
  (tempPath, tempHandle) <- openTempFile tempDir "cpphs-stderr.txt"
  originalStderr <- hDuplicate stderr
  let restoreStderr = hFlush stderr >> hDuplicateTo originalStderr stderr
      cleanup = do
        hClose tempHandle
        hClose originalStderr
        removeFile tempPath
  E.bracketOnError
    (pure ())
    (const (restoreStderr >> cleanup))
    $ \() -> do
      hFlush stderr
      hDuplicateTo tempHandle stderr
      result <- action `E.finally` restoreStderr
      hClose tempHandle
      captured <- withFile tempPath ReadMode TIO.hGetContents
      hClose originalStderr
      removeFile tempPath
      pure (result, captured)

stderrSuffix :: Text -> String
stderrSuffix captured
  | T.null stripped = ""
  | otherwise = " stderr=" <> T.unpack stripped
  where
    stripped = T.strip captured

loadManifest :: IO [CaseMeta]
loadManifest = do
  raw <- TIO.readFile manifestPath
  let rows = filter (not . T.null) (map stripComment (T.lines raw))
  mapM parseRow rows

stripComment :: Text -> Text
stripComment line =
  let core = fst (T.breakOn "#" line)
   in T.strip core

parseRow :: Text -> IO CaseMeta
parseRow row =
  case T.splitOn "\t" row of
    [cid, cat, pathTxt, expectedTxt] -> parseRowWithReason cid cat pathTxt expectedTxt ""
    [cid, cat, pathTxt, expectedTxt, reasonTxt] -> parseRowWithReason cid cat pathTxt expectedTxt reasonTxt
    _ -> fail ("Invalid manifest row (expected 4 or 5 tab-separated columns): " <> T.unpack row)

parseRowWithReason :: Text -> Text -> Text -> Text -> Text -> IO CaseMeta
parseRowWithReason cid cat pathTxt expectedTxt reasonTxt = do
  let path = T.unpack pathTxt
  exists <- doesFileExist (fixtureRoot </> path)
  if not exists
    then fail ("Manifest references missing case file: " <> path)
    else do
      expected <-
        case expectedTxt of
          "pass" -> pure ExpectPass
          "xfail" -> pure ExpectXFail
          _ -> fail ("Unknown expected value in manifest: " <> T.unpack expectedTxt)
      let reason = trim (T.unpack reasonTxt)
      case expected of
        ExpectXFail | null reason -> fail ("xfail case requires reason: " <> T.unpack cid)
        _ -> pure ()
      pure
        CaseMeta
          { caseId = T.unpack cid,
            caseCategory = T.unpack cat,
            casePath = path,
            caseExpected = expected,
            caseReason = reason
          }

trim :: String -> String
trim = dropWhile isSpace . dropWhileEnd isSpace