tilia-0.0.1.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
( answeredLeaves,
answeredLinearLeaves,
blankCpp,
countLeaves,
describeCppError,
formatWithCpp,
usesCpp,
)
import Tilia.Diff (diff)
import Tilia.Doc (defaultRenderOptions, printDoc)
import Tilia.Equivalence (commentDifference, syntaxDifference)
import Tilia.Palette (Palette, paletteFor)
import Tilia.Parser
( ParseError (..),
ParsedModule (..),
ParserConfig,
parseModule,
parserConfigFor,
)
import Tilia.Pragma (effectiveExtensions, movesPositions)
import Tilia.Render (RenderConfig, defaultRenderConfig, renderModule)
import Tilia.Source (comments)
import Tilia.Span (spanStartColumn, spanStartLine)
import Tilia.Span.Ghc (spanOfSrcSpan)
import Tilia.TestConfig (exampleRenderConfig)
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" . pendingWith $
"corpus not on this machine and could not be fetched: " <> T.unpack problem
Right examples -> do
palette <- runIO paletteFor
let run = check palette
case corpusExpectations corpus of
Listed lists -> againstLists lists run examples
Recorded path -> againstRecord path run 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)
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))
| 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 had = Set.fromList (map exampleName examples)
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
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
-- | 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 -> [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
(pmModule before, pmModule after)
(comments (pmSource before))
(comments (pmSource 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 = effectiveExtensions package source
parse = either (const Nothing) Just . parseModule config path
render parsed =
printDoc
defaultRenderOptions
(renderModule (exampleRenderConfig package source (pmModule parsed)) parsed)
----------------------------------------------------------------------------
-- Checking an example that involved the preprocessor
-- | Everything that can be established about an example with conditionals in
-- it.
checkCpp :: Palette -> FilePath -> [Extension] -> Text -> Maybe Text -> Result
checkCpp palette path package source expected = case formatWithCpp parser render 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 formatWithCpp parser render 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
(pmModule before, pmModule after)
(comments (pmSource before))
(comments (pmSource after)) ->
Just ("comments, in one configuration: " <> difference)
| otherwise -> Nothing
parse = parseModule parser path
parser = parserConfigFor package
render = renderConfigFor parser path package source
-- | 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
-- | What to print an example's configurations with.
renderConfigFor :: ParserConfig -> FilePath -> [Extension] -> Text -> RenderConfig
renderConfigFor parser path package source =
case parseModule parser path (blankCpp source) of
Right whole -> exampleRenderConfig package source (pmModule whole)
Left _ -> defaultRenderConfig