packages feed

tilia-0.1.2.0: tests/Tilia/CorpusSpec.hs

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}

-- | Formatting other people's Haskell.
module Tilia.CorpusSpec (spec) where

import Control.Exception (SomeException, evaluate, try)
import Control.Monad (join, unless)
import Data.ByteString qualified as BS
import Data.Foldable (for_)
import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding (decodeUtf8')
import GHC.LanguageExtensions.Type (Extension)
import System.FilePath (replaceExtension)
import Test.Hspec hiding (Example, after, before, example)
import Tilia.Corpus
import Tilia.Corpus.Manifest
import Tilia.Cpp (describeCppError, usesCpp)
import Tilia.Diff qualified as Diff
import Tilia.Equivalence (commentDifference, syntaxDifference)
import Tilia.Palette (Color (Meta), Palette (Plain), paint, paletteFor)
import Tilia.Parser
  ( ParseError (..),
    ParsedModule (..),
    parseModule,
  )
import Tilia.Pragma (movesPositions)
import Tilia.Span (spanStartColumn, spanStartLine)
import Tilia.Span.Ghc (spanOfSrcSpan)
import Tilia.TestConfig (formatCppExample, printExample)
import Tilia.TestUtils
  ( answeredLeaves,
    answeredLinearLeaves,
    countLeaves,
    effectiveExtensionsFor,
    parserConfigFor,
  )

spec :: Spec
spec = do
  corpusSpec vendoredExamples
  corpusSpec ormoluExamples
  corpusSpec ghcTestSuite
  corpusSpec hackagePackages

-- | Every example of one corpus.
corpusSpec :: Corpus -> Spec
corpusSpec corpus =
  describe (corpusName corpus) $
    runIO (obtain corpus) >>= \case
      Left problem ->
        it "is available" . expectationFailure $
          "corpus not on this machine and could not be fetched: " <> T.unpack problem
      Right examples -> do
        palette <- runIO paletteFor
        case corpusExpectations corpus of
          Listed lists -> againstLists lists (check palette) examples
          -- Plain, so that what a record says does not depend on whether the
          -- run that wrote it printed to a terminal.
          Recorded path -> againstRecord path (check Plain) examples

-- | A corpus small enough to name its exceptions in "Tilia.Corpus".
againstLists :: Lists -> (Example -> IO Result) -> [Example] -> Spec
againstLists listed run examples =
  parallel $ for_ examples $ \example ->
    it (exampleName example) $ do
      result <- run example
      case verdict (Set.member (exampleName example) declines) result of
        Passes -> pure ()
        Fails why -> expectationFailure (T.unpack why)
        Reserved why -> pendingWith (T.unpack why)
  where
    declines = Set.fromList (expectDeclined listed)

-- | A corpus checked against a generated record of what it does.
againstRecord :: FilePath -> (Example -> IO Result) -> [Example] -> Spec
againstRecord path run examples = do
  accept <- runIO accepting
  if accept then accepted else checked
  where
    reportPath = replaceExtension path ".report"

    accepted = do
      seen <- runIO (newIORef [])
      afterAll_ (record seen) $
        parallel $
          for_ examples $ \example ->
            it (exampleName example) $ do
              Result outcome why digest <- run example
              note seen (exampleName example, Entry outcome digest, T.take reasonLength why)

    record seen = do
      noted <- readIORef seen
      if length noted /= length examples
        then
          putStrLn $
            "not writing "
              <> path
              <> ": "
              <> show (length noted)
              <> " of "
              <> show (length examples)
              <> " examples ran, so this run does not know what the rest do."
              <> " Regenerate without --match."
        else do
          writeManifest path (Map.fromList [(n, e) | (n, e, _) <- noted])
          writeReport reportPath [(n, entryOutcome e, w) | (n, e, w) <- noted]

    note :: IORef [a] -> a -> IO ()
    note seen entry = atomicModifyIORef' seen (\es -> (entry : es, ()))

    checked = do
      manifest <- runIO (readManifest path)
      report <- runIO (readReport reportPath)
      parallel $ for_ examples $ \example ->
        it (exampleName example) $ do
          Result outcome why digest <- run example
          case Map.lookup (exampleName example) manifest of
            Nothing -> expectationFailure (T.unpack (unrecorded outcome))
            Just expected
              | entryOutcome expected /= outcome ->
                  expectationFailure (T.unpack (moved (entryOutcome expected) outcome why))
              | entryDigest expected /= digest ->
                  expectationFailure (T.unpack (rewritten (entryDigest expected) digest))
              | Just problem <-
                  misreported
                    (Map.lookup (exampleName example) report)
                    outcome
                    (reasonLines (T.take reasonLength why)) ->
                  expectationFailure (T.unpack problem)
              | otherwise -> case outcome of
                  Formatted -> pure ()
                  Declined -> pure ()
                  DoesNotParse -> pure ()
                  NotUtf8 -> pure ()
                  PartlyChecked -> pendingWith (T.unpack why)
                  Broken -> pendingWith (T.unpack (T.take reasonLength why))
      it "records nothing it does not have" $ do
        let gone = [n | n <- Map.keys manifest, not (Set.member n had)]
        unless (null gone) . expectationFailure $
          show (length gone)
            <> " entries name examples this corpus does not have, starting with "
            <> unwords (take 5 gone)
            <> regenerate
      it "reports nothing it does not have" $ do
        let gone = [n | n <- Map.keys report, not (Set.member n had)]
        unless (null gone) . expectationFailure $
          reportPath
            <> " gives reasons for "
            <> show (length gone)
            <> " examples this corpus does not have, starting with "
            <> unwords (take 5 gone)
            <> regenerate

    had = Set.fromList (fmap exampleName examples)

    misreported filed outcome reason = case (outcome, filed) of
      (Formatted, Nothing) -> Nothing
      (Formatted, Just _) ->
        Just (T.pack reportPath <> " gives a reason why this is not formatted, and it is" <> T.pack regenerate)
      (_, Just (filedUnder, given))
        | filedUnder == outcome && given == reason -> Nothing
      _ ->
        Just $
          T.pack reportPath
            <> " says otherwise why this "
            <> outcomeName outcome
            <> ", which is now:\n"
            <> T.unlines reason
            <> T.pack regenerate

    unrecorded outcome =
      "this is not in "
        <> T.pack path
        <> ", and it "
        <> outcomeName outcome
        <> T.pack regenerate

    rewritten was now =
      T.pack path
        <> " says this comes out as "
        <> was
        <> ", and it comes out as "
        <> now
        <> T.pack regenerate

    moved expected outcome why =
      T.pack path
        <> " says this "
        <> outcomeName expected
        <> ", and it "
        <> outcomeName outcome
        <> (if T.null why then "" else ": " <> why)
        <> T.pack regenerate

    regenerate =
      "\n\nIf that is the intended change, regenerate the record:"
        <> "\n    TILIA_CORPUS_ACCEPT=1 cabal test"

-- | How much of an example's reason a record keeps.
reasonLength :: Int
reasonLength = 2000

----------------------------------------------------------------------------
-- Checking one example

-- | What running the formatter over one example established, and why.
data Result = Result
  { -- | The outcome.
    resultOutcome :: Outcome,
    -- | Explanation in text.
    resultWhy :: Text,
    -- | A digest of what the formatter wrote, or
    -- 'Tilia.Corpus.Manifest.noDigest' where it wrote nothing.
    resultDigest :: Text
  }

-- | What the runner should do about what one example produced.
data Verdict
  = -- | Nothing to report.
    Passes
  | -- | Something is wrong, and this is what.
    Fails Text
  | -- | Neither: everything that was asked of it held, and something worth
    -- asking went unasked. Reported rather than passed over, so that the
    -- number of examples whose properties were only partly established is
    -- visible in the summary instead of implied by its absence.
    Reserved Text

-- | What is to be said about what one example produced.
verdict ::
  -- | Does the corpus say this one should be declined?
  Bool ->
  Result ->
  Verdict
verdict declines (Result outcome why _) = case outcome of
  Broken -> Fails why
  NotUtf8 -> Fails (unlisted "is not UTF-8")
  DoesNotParse -> Fails (unlisted ("does not parse, at " <> why))
  Declined
    | declines -> Passes
    | otherwise -> Fails "the formatter declined this, and the corpus does not say it should"
  PartlyChecked
    | declines -> Fails wasNotDeclined
    | otherwise -> Reserved why
  Formatted
    | declines -> Fails wasNotDeclined
    | otherwise -> Passes
  where
    wasNotDeclined = "the corpus says this should be declined, and it was not"
    unlisted what =
      "this " <> what <> ", and the corpus does not list it under expectSkip"

check :: Palette -> Example -> IO Result
check palette example = do
  source <- readUtf8 (exampleInput example)
  expected <- traverse readUtf8 (exampleReference example)
  case source of
    Nothing -> pure (Result NotUtf8 "" noDigest)
    Just text ->
      guarded (checkPure palette (exampleName example) (exampleExtensions example) text (join expected))

-- | Read a file that is supposed to be a Haskell module.
readUtf8 :: FilePath -> IO (Maybe Text)
readUtf8 path = either (const Nothing) Just . decodeUtf8' <$> BS.readFile path

-- | A unified diff, cut short after 60 lines, past which it stops being
-- read.
diff :: Palette -> (Text, Text) -> Text -> Text -> Text
diff palette names before after
  | length body > room = T.intercalate "\n" (heading <> take room body <> [omitted])
  | otherwise = whole
  where
    whole = Diff.diff palette names before after
    (heading, body) = splitAt 2 (T.lines whole)
    room = 60
    omitted =
      paint palette Meta $
        "… and " <> T.pack (show (length body - room)) <> " more lines"

-- | Run a check, turning a crash into a failure rather than into a dead test
-- run.
guarded :: Result -> IO Result
guarded result =
  try (evaluate (forced result)) >>= \case
    Left (e :: SomeException) ->
      pure (Result Broken ("the formatter raised an error: " <> firstLine (T.pack (show e))) noDigest)
    Right settled -> pure settled
  where
    forced r =
      resultOutcome r
        `seq` T.length (resultWhy r)
        `seq` T.length (resultDigest r)
        `seq` r
    firstLine = T.strip . T.takeWhile (/= '\n')

-- | Everything that can be established about one example without doing any
-- more input or output.
checkPure :: Palette -> FilePath -> Maybe [Extension] -> Text -> Maybe Text -> Result
checkPure palette path package source expected
  | movesPositions source =
      Result Declined "a pragma that moves positions, which we do not rewrite" noDigest
  | usesCpp inForce source = checkCpp palette path package source expected
  | otherwise = case parseModule config path source of
      Left problem -> Result DoesNotParse (parseProblem problem) noDigest
      Right before ->
        let formatted = render before
            against name = diff palette ("input", name) source formatted
         in case parse formatted of
              Nothing ->
                told
                  formatted
                  Broken
                  ( "the formatted output does not parse\n"
                      <> against "output (does not parse)"
                  )
              Just after
                | Just difference <- syntaxDifference (pmModule before) (pmModule after) ->
                    told
                      formatted
                      Broken
                      ( "a different program: "
                          <> difference
                          <> "\n"
                          <> against "output"
                      )
                | Just difference <- commentDifference before after ->
                    told
                      formatted
                      Broken
                      ( "comments: "
                          <> difference
                          <> "\n"
                          <> against "output"
                      )
                | settled <- render after,
                  settled /= formatted ->
                    told
                      formatted
                      Broken
                      ( "formatting is non-idempotent\n"
                          <> diff palette ("first pass", "second pass") formatted settled
                      )
                | Just reference <- expected,
                  reference /= formatted ->
                    told
                      formatted
                      Broken
                      ( "does not match the corpus's expected output\n"
                          <> diff palette ("expected", "ours") reference formatted
                      )
                | otherwise -> told formatted Formatted ""
  where
    told formatted outcome why = Result outcome why (digestOf formatted)
    config = parserConfigFor package
    inForce = effectiveExtensionsFor package source
    parse = either (const Nothing) Just . parseModule config path
    render = printExample package source

----------------------------------------------------------------------------
-- Checking an example that involved the preprocessor

-- | Everything that can be established about an example with conditionals in
-- it.
checkCpp :: Palette -> FilePath -> Maybe [Extension] -> Text -> Maybe Text -> Result
checkCpp palette path package source expected = case formatCppExample package path source of
  Left why -> Result Declined (describeCppError why) noDigest
  Right formatted -> case (countLeaves source, countLeaves formatted) of
    (Left why, _) ->
      told formatted Broken ("the input's configurations: " <> describeCppError why)
    (_, Left why) ->
      told formatted Broken ("the output's configurations: " <> describeCppError why <> "\n" <> against formatted)
    (Right went, Right came)
      | went /= came ->
          told
            formatted
            Broken
            ( "formatting changed how many configurations there are, from "
                <> count went
                <> " to "
                <> count came
                <> "\n"
                <> against formatted
            )
      | went <= configurationsToCheck -> quantified answeredLeaves Nothing formatted
      | otherwise ->
          quantified
            answeredLinearLeaves
            ( Just
                ( count went
                    <> " configurations is more than the "
                    <> count configurationsToCheck
                    <> " this checks, so only the ones varying a single"
                    <> " conditional were compared"
                )
            )
            formatted
  where
    told formatted outcome why = Result outcome why (digestOf formatted)
    count :: Integer -> Text
    count = T.pack . show
    against formatted = diff palette ("input", "output") source formatted
    quantified enumerate reservation formatted =
      case (enumerate source, enumerate formatted) of
        (Left why, _) ->
          told formatted Broken ("the input's configurations: " <> describeCppError why)
        (_, Left why) ->
          told formatted Broken ("the output's configurations: " <> describeCppError why <> "\n" <> against formatted)
        (Right went, Right came)
          | (why : _) <- alongside went came ->
              told formatted Broken (why <> "\n" <> against formatted)
          | otherwise -> case formatCppExample package path formatted of
              Left why ->
                told formatted Broken ("the output cannot be formatted again: " <> describeCppError why)
              Right settled
                | settled /= formatted ->
                    told
                      formatted
                      Broken
                      ( "formatting is non-idempotent\n"
                          <> diff palette ("first pass", "second pass") formatted settled
                      )
                | Just reference <- expected,
                  reference /= formatted ->
                    told
                      formatted
                      Broken
                      ( "does not match the corpus's expected output\n"
                          <> diff palette ("expected", "ours") reference formatted
                      )
                | otherwise ->
                    maybe (told formatted Formatted "") (told formatted PartlyChecked) reservation
    alongside went came =
      [ why
      | (answers, before) <- went,
        why <- case Map.lookup answers output of
          Nothing -> ["a configuration of the input the output does not have"]
          Just after -> maybe [] pure (sameProgram before after)
      ]
        <> [ "a configuration of the output the input does not have"
           | any (\(answers, _) -> not (Map.member answers input)) came
           ]
      where
        output = Map.fromList came
        input = Map.fromList went

    sameProgram went came = case (parse went, parse came) of
      (Left problem, _) ->
        Just ("a configuration of the input does not parse: " <> parseProblem problem)
      (_, Left problem) ->
        Just
          ( "a configuration of the output does not parse: "
              <> parseProblem problem
              <> "\n"
              <> linesAround came problem
          )
      (Right before, Right after)
        | Just difference <- syntaxDifference (pmModule before) (pmModule after) ->
            Just ("a different program, in one configuration: " <> difference)
        | Just difference <- commentDifference before after ->
            Just ("comments, in one configuration: " <> difference)
        | otherwise -> Nothing

    parse = parseModule parser path

    parser = parserConfigFor package

-- | Why a parse failed, and where in the file, but not which file.
--
-- The example being reported already names it, and 'describeParseError'
-- opens with the path in full.
parseProblem :: ParseError -> Text
parseProblem problem = at <> peProblem problem
  where
    at = case spanOfSrcSpan (peSpan problem) of
      Nothing -> T.empty
      Just s ->
        T.pack (show (spanStartLine s))
          <> ":"
          <> T.pack (show (spanStartColumn s))
          <> ": "

-- | The lines of a configuration around the one a parse error names.
linesAround :: Text -> ParseError -> Text
linesAround text problem = case spanStartLine <$> spanOfSrcSpan (peSpan problem) of
  Nothing -> T.empty
  Just line ->
    T.unlines
      [ (if n == line then "> " else "  ") <> T.pack (show n) <> "  " <> l
      | (n, l) <- zip [1 :: Int ..] (T.lines text),
        abs (n - line) <= 4
      ]

-- | How many configurations one example gets compared over.
configurationsToCheck :: Integer
configurationsToCheck = 64