packages feed

aihc-parser-1.0.0.2: common/ParserErrorGolden.hs

{-# LANGUAGE OverloadedStrings #-}

module ParserErrorGolden
  ( Outcome (..),
    ErrorMessageCase (..),
    fixtureRoot,
    loadErrorMessageCases,
    parseErrorMessageCaseText,
    evaluateErrorMessageCase,
    progressSummary,
  )
where

import Aihc.Cpp (Result (..))
import Aihc.Parser (ParserConfig (..), defaultConfig, formatParseErrors, parseModule)
import Aihc.Parser.Syntax qualified as Syntax
import CppSupport (cppEnabledInSource, preprocessForParserWithoutIncludes)
import Data.Aeson ((.:))
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap qualified as KeyMap
import Data.Aeson.Types (parseEither, withObject)
import Data.Char (toLower)
import Data.List (sort)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding (encodeUtf8)
import Data.Text.IO qualified as TIO
import Data.Yaml qualified as Y
import GhcOracle (oracleModuleAstFingerprint)
import System.Directory (doesDirectoryExist, listDirectory)
import System.FilePath (makeRelative, takeDirectory, takeExtension, (</>))

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

data ErrorMessageCase = ErrorMessageCase
  { caseId :: !String,
    caseCategory :: !String,
    casePath :: !FilePath,
    caseSource :: !Text,
    caseExpectedGhc :: !Text,
    caseExpectedAihc :: !Text
  }
  deriving (Eq, Show)

fixtureRoot :: FilePath
fixtureRoot = "test/Test/Fixtures/error-messages"

loadErrorMessageCases :: IO [ErrorMessageCase]
loadErrorMessageCases = do
  exists <- doesDirectoryExist fixtureRoot
  if not exists
    then pure []
    else do
      paths <- listFixtureFiles fixtureRoot
      mapM loadErrorMessageCase paths

loadErrorMessageCase :: FilePath -> IO ErrorMessageCase
loadErrorMessageCase path = do
  source <- TIO.readFile path
  case parseErrorMessageCaseText path source of
    Left err -> fail err
    Right parsed -> pure parsed

parseErrorMessageCaseText :: FilePath -> Text -> Either String ErrorMessageCase
parseErrorMessageCaseText path source = do
  value <-
    case Y.decodeEither' (encodeUtf8 source) of
      Left err -> Left ("Invalid YAML fixture " <> path <> ": " <> Y.prettyPrintParseException err)
      Right parsed -> Right parsed
  (inputText, ghcText, aihcText) <- parseYamlFixture path value
  let relPath = dropRootPrefix path
      category = categoryFromPath relPath
  pure
    ErrorMessageCase
      { caseId = relPath,
        caseCategory = category,
        casePath = relPath,
        caseSource = inputText,
        caseExpectedGhc = normalizeText ghcText,
        caseExpectedAihc = normalizeText aihcText
      }

parseYamlFixture :: FilePath -> Y.Value -> Either String (Text, Text, Text)
parseYamlFixture path value =
  case parseEither
    ( withObject "parser error fixture" $ \obj -> do
        case validateAllowedKeys path obj ["src", "ghc", "aihc"] of
          Left err -> fail err
          Right () -> pure ()
        inputText <- obj .: "src"
        ghcText <- obj .: "ghc"
        aihcText <- obj .: "aihc"
        pure (inputText, ghcText, aihcText)
    )
    value of
    Left err -> Left ("Invalid parser error fixture schema in " <> path <> ": " <> err)
    Right parsed -> Right parsed

evaluateErrorMessageCase :: ErrorMessageCase -> (Outcome, String)
evaluateErrorMessageCase meta =
  case ghcMismatch meta of
    Just details -> (OutcomeFail, details)
    Nothing ->
      case renderAihcMessage meta of
        Left details -> (OutcomeFail, details)
        Right actual
          | actual == caseExpectedAihc meta -> (OutcomePass, "")
          | otherwise ->
              ( OutcomeFail,
                "aihc error mismatch.\nEXPECTED:\n"
                  <> T.unpack (caseExpectedAihc meta)
                  <> "\nACTUAL:\n"
                  <> T.unpack actual
              )

ghcMismatch :: ErrorMessageCase -> Maybe String
ghcMismatch meta =
  case oracleModuleAstFingerprint sourceName Syntax.Haskell2010Edition [] (caseSource meta) of
    Right {} ->
      Just "expected GHC parse failure, but parser accepted the input."
    Left actual
      | normalizeText actual == caseExpectedGhc meta -> Nothing
      | otherwise ->
          Just
            ( "GHC error mismatch. expected="
                <> show (T.unpack (caseExpectedGhc meta))
                <> " actual="
                <> show (T.unpack (normalizeText actual))
            )

renderAihcMessage :: ErrorMessageCase -> Either String Text
renderAihcMessage meta =
  let source = caseSource meta
      preprocessed =
        if cppEnabledInSource source
          then resultOutput (preprocessForParserWithoutIncludes sourceName [] source)
          else source
      (errs, _) = parseModule defaultConfig {parserSourceName = sourceName} preprocessed
   in case errs of
        _ : _ -> Right (normalizeText (T.pack (formatParseErrors sourceName (Just preprocessed) errs)))
        [] -> Left "aihc parser accepted the input"

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

sourceName :: FilePath
sourceName = "test.hs"

normalizeText :: Text -> Text
normalizeText = normalizeLines . dropTrailingNewlines . normalizeLineEndings

normalizeLineEndings :: Text -> Text
normalizeLineEndings = T.replace "\r\n" "\n"

dropTrailingNewlines :: Text -> Text
dropTrailingNewlines = T.dropWhileEnd (`elem` ['\n', '\r'])

normalizeLines :: Text -> Text
normalizeLines = T.intercalate "\n" . map T.stripEnd . T.lines

listFixtureFiles :: FilePath -> IO [FilePath]
listFixtureFiles root = do
  entries <- listDirectory root
  paths <- mapM visit (sort entries)
  pure (concat paths)
  where
    visit name
      | isHidden name = pure []
      | otherwise = do
          let path = root </> name
          isDir <- doesDirectoryExist path
          if isDir
            then listFixtureFiles path
            else pure [path | isYamlFile path]

dropRootPrefix :: FilePath -> FilePath
dropRootPrefix = makeRelative fixtureRoot

categoryFromPath :: FilePath -> String
categoryFromPath relPath =
  case takeDirectory relPath of
    "." -> "error-messages"
    dir -> dir

isYamlFile :: FilePath -> Bool
isYamlFile path =
  let ext = map toLower (takeExtension path)
   in ext == ".yaml" || ext == ".yml"

isHidden :: FilePath -> Bool
isHidden [] = False
isHidden (c : _) = c == '.'

validateAllowedKeys :: FilePath -> KeyMap.KeyMap v -> [Text] -> Either String ()
validateAllowedKeys path obj allowed =
  let allowedSet = sort allowed
      extras =
        sort
          [ Key.toText key
          | key <- KeyMap.keys obj,
            Key.toText key `notElem` allowedSet
          ]
   in case extras of
        [] -> Right ()
        _ -> Left ("Unexpected keys " <> show extras <> " in " <> path)