packages feed

tilia-0.1.3.0: tests/Tilia/Corpus/Manifest.hs

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

-- | What a corpus too large to argue about example by example is expected to
-- do.
module Tilia.Corpus.Manifest
  ( -- * What an example does
    Outcome (..),
    outcomeName,

    -- * Records of it
    Entry (..),
    digestOf,
    noDigest,
    Manifest,
    readManifest,
    writeManifest,
    Report,
    readReport,
    writeReport,
    reasonLines,
  )
where

import Crypto.Hash.SHA256 qualified as SHA256
import Data.ByteString.Base16 qualified as B16
import Data.List (sortOn)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as T
import System.Directory (createDirectoryIfMissing)
import System.FilePath (takeDirectory)
import Tilia.Utils (readUtf8File, writeUtf8File)

----------------------------------------------------------------------------
-- What an example does

-- | What running the formatter over one example established, coarsely enough
-- to write down and compare.
data Outcome
  = -- | Nothing was found wrong with it.
    Formatted
  | -- | Nothing was found wrong with it, and one of the things worth asking
    -- went unasked because it could not be afforded. See
    -- 'Tilia.CorpusSpec.checkCpp'.
    PartlyChecked
  | -- | The input is Haskell, and the formatter refuses to rewrite it.
    Declined
  | -- | GHC's own parser could not read it. Common in a corpus of real
    -- releases, which carry files that are Haskell templates, files written
    -- for compilers other than GHC, and files whose @CPP@ we do not expand.
    DoesNotParse
  | -- | The bytes are not UTF-8, so there was nothing to parse.
    NotUtf8
  | -- | A property does not hold. The work list.
    Broken
  deriving (Eq, Ord, Show, Enum, Bounded)

-- | What an outcome is called in a manifest.
outcomeName :: Outcome -> Text
outcomeName = \case
  Formatted -> "formatted"
  PartlyChecked -> "partly-checked"
  Declined -> "declined"
  DoesNotParse -> "does-not-parse"
  NotUtf8 -> "not-utf8"
  Broken -> "broken"

-- | Reading one back, by the name it was written under.
outcomeNamed :: Text -> Maybe Outcome
outcomeNamed name =
  lookup name [(outcomeName o, o) | o <- [minBound .. maxBound]]

----------------------------------------------------------------------------
-- Records of it

-- | What one example did, and what it produced.
data Entry = Entry
  { -- | Expected outcome
    entryOutcome :: Outcome,
    -- | A digest of what the formatter wrote, or 'noDigest' where it wrote
    -- nothing.
    entryDigest :: Text
  }
  deriving (Eq, Show)

-- | A short digest of an example's output.
digestOf :: Text -> Text
digestOf = T.take 12 . T.decodeUtf8 . B16.encode . SHA256.hash . T.encodeUtf8

-- | What stands in the digest's place where the formatter wrote nothing.
noDigest :: Text
noDigest = "-"

type Manifest = Map FilePath Entry

-- | Read a manifest.
--
-- A missing one is an empty one rather than an error, so that a corpus added
-- to this repository before its manifest has been generated says which
-- examples it does not know about, one line each, instead of failing once
-- with a message about a file.
readManifest :: FilePath -> IO Manifest
readManifest path =
  readUtf8File path >>= \case
    Nothing -> pure Map.empty
    Just text -> pure (Map.fromList (concatMap entry (T.lines text)))
  where
    entry line = case T.words line of
      (what : digest : rest)
        | not ("#" `T.isPrefixOf` what),
          Just outcome <- outcomeNamed what,
          not (null rest) ->
            [(T.unpack (T.unwords rest), Entry outcome digest)]
      _ -> []

-- | Write a manifest, sorted by name so that a regeneration diff shows what
-- changed rather than what moved.
writeManifest :: FilePath -> Manifest -> IO ()
writeManifest path manifest = do
  createDirectoryIfMissing True (takeDirectory path)
  writeUtf8File path $
    T.unlines (header <> fmap line (Map.toAscList manifest))
  where
    header =
      [ "# What each example of this corpus does today, one line each.",
        "# Generated: run the test suite with TILIA_CORPUS_ACCEPT=1.",
        "# See Tilia.Corpus.Manifest for what the outcomes mean, and the",
        "# matching .report for why each example that is not `formatted` is",
        "# not.",
        ""
      ]
    line (name, entry) =
      T.justifyLeft width ' ' (outcomeName (entryOutcome entry))
        <> T.justifyLeft 14 ' ' (entryDigest entry)
        <> T.pack name
    width =
      2 + maximum (1 : fmap (T.length . outcomeName . entryOutcome) (Map.elems manifest))

-- | The reasons a report gives, by example: the outcome it is filed under,
-- and the lines that say why.
type Report = Map FilePath (Outcome, [Text])

-- | Read a report back. A missing one is an empty one, as with
-- 'readManifest'.
readReport :: FilePath -> IO Report
readReport path =
  readUtf8File path >>= \case
    Nothing -> pure Map.empty
    Just text -> pure (go Nothing Map.empty (T.lines text))
  where
    go section report = \case
      [] -> report
      rule : heading : rule' : rest
        | ruled rule,
          ruled rule',
          Just outcome <- outcomeNamed (fst (T.breakOn " (" heading)) ->
            go (Just outcome) report rest
      name : rest
        | Just outcome <- section,
          not (T.null name),
          not (" " `T.isPrefixOf` name) ->
            let (reason, rest') = span ("    " `T.isPrefixOf`) rest
             in go section (Map.insert (T.unpack name) (outcome, reason) report) rest'
      _ : rest -> go section report rest
    ruled line = not (T.null line) && T.all (== '=') line

-- | Write the reasons beside the record.
writeReport :: FilePath -> [(FilePath, Outcome, Text)] -> IO ()
writeReport path entries = do
  createDirectoryIfMissing True (takeDirectory path)
  writeUtf8File path $
    T.unlines (header <> concatMap section grouped)
  where
    interesting = [e | e@(_, outcome, _) <- entries, outcome /= Formatted]
    grouped =
      [ (outcome, [(name, why) | (name, o, why) <- sortOn first interesting, o == outcome])
      | outcome <- sections
      ]
    first (name, _, _) = name
    sections = [Broken, DoesNotParse, PartlyChecked, Declined, NotUtf8]
    header =
      [ "Hackage modules that currently do not format cleanly.",
        "",
        T.pack (show (length entries))
          <> " examples, "
          <> T.pack (show (length entries - length interesting))
          <> " formatted.",
        ""
      ]
    section (_, []) = []
    section (outcome, es) =
      [ T.replicate 74 "=",
        outcomeName outcome <> " (" <> T.pack (show (length es)) <> ")",
        T.replicate 74 "=",
        ""
      ]
        <> concatMap entry es
    entry (name, why) = [T.pack name] <> reasonLines why <> [""]

-- | The lines a report gives an example's reason.
reasonLines :: Text -> [Text]
reasonLines = fmap ("    " <>) . T.lines . T.strip