packages feed

tilia-0.1.3.0: tests/Tilia/TestUtils.hs

{-# LANGUAGE OverloadedLabels #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternSynonyms #-}

-- | Helpers several specs share.
module Tilia.TestUtils
  ( withEnvironment,
    withFiles,
    withOpsProject,
    switchedOn,
    underAsciiLocale,
    parserConfigFor,
    defaultParserConfig,
    effectiveExtensionsFor,
    parsed,
    parses,
    commentsIn,
    haskellFilesIn,
    unreadOnly,
    inBothNamespaces,
    fingerprint,
    routedResolver,

    -- * Configurations, enumerated
    configurations,
    leaves,
    linearLeaves,
    countLeaves,
    answeredLeaves,
    answeredLinearLeaves,
  )
where

import Control.Exception (bracket)
import Data.Bits (xor)
import Data.Char (ord)
import Data.Choice (pattern Do)
import Data.Either (isRight)
import Data.Foldable (for_, traverse_)
import Data.List (isSuffixOf, sort)
import Data.List.NonEmpty (NonEmpty (..))
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe, isJust, listToMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Word (Word64)
import GHC.Driver.Session qualified as GHC
import GHC.IO.Encoding (getLocaleEncoding, setLocaleEncoding)
import GHC.LanguageExtensions.Type (Extension (..))
import System.Directory
  ( canonicalizePath,
    createDirectoryIfMissing,
    doesDirectoryExist,
    listDirectory,
  )
import System.Environment (lookupEnv, setEnv, unsetEnv)
import System.FilePath (takeBaseName, takeDirectory, takeExtension, (</>))
import System.IO (mkTextEncoding)
import System.IO.Temp (withSystemTempDirectory)
import Tilia.Cabal.Package (newPackageReader, sourceExtensions)
import Tilia.Cabal.Plan (BuildPlan, plannedCompiler)
import Tilia.Comments (Comment)
import Tilia.Cpp.Directives
  ( Assignment,
    Configurations (..),
    CppError,
    GroupSpec (..),
    Guard,
    configurationsOn,
    gsCount,
    implied,
    nestedIn,
    readConditionals,
    scanConditionals,
    unconditionalErrors,
    withoutOpaque,
  )
import Tilia.Fixity (Fixities, Fixity, ModuleChain (..), Namespace (..), OpName)
import Tilia.Fixity.Plan (Resolver, Route, newResolverVia)
import Tilia.Parser
  ( ParsedModule (..),
    ParserConfig (..),
    describeParseError,
    parseModule,
  )
import Tilia.Pragma (Defaults, defaultsOf, effectiveExtensions)
import Tilia.Source (comments)
import Tilia.Utils (quietly, writeUtf8File)

-- | Run something with the given variables set, and the environment as it
-- was afterwards however it turns out.
withEnvironment :: [(String, String)] -> IO a -> IO a
withEnvironment vars act = bracket set restore (const act)
  where
    set = traverse remember vars
    remember (key, value) = do
      was <- lookupEnv key
      setEnv key value
      pure (key, was)
    restore = traverse_ (\(key, was) -> maybe (unsetEnv key) (setEnv key) was)

-- | Run something on a new temporary directory, by its canonical path,
-- holding these files at their paths in it.
withFiles :: [(FilePath, Text)] -> (FilePath -> IO a) -> IO a
withFiles files act =
  withSystemTempDirectory "tilia" $ \dir -> do
    root <- canonicalizePath dir
    for_ files $ \(path, text) -> do
      createDirectoryIfMissing True (takeDirectory (root </> path))
      writeUtf8File (root </> path) text
    act root

-- | Run something on a project of one package, @fake@, whose module @Ops@
-- declares @infixl 6 <+>@, and on a plan for it kept outside it that names
-- the project relatively, as @haskell.nix@ writes it.
withOpsProject ::
  -- | Further files of the project, by their paths in it, each @.hs@ file
  -- one more module of the package.
  [(FilePath, Text)] ->
  -- | Further entries of the plan.
  [Text] ->
  -- | Given the project's directory and the plan's path.
  (FilePath -> FilePath -> IO a) ->
  IO a
withOpsProject files others act =
  withFiles
    ( [ ("project" </> "fake.cabal", cabal),
        ("project" </> "src" </> "Ops.hs", ops),
        ("elsewhere" </> "plan.json", plan)
      ]
        <> [("project" </> path, text) | (path, text) <- files]
    )
    $ \dir -> act (dir </> "project") (dir </> "elsewhere" </> "plan.json")
  where
    cabal =
      T.unlines
        [ "cabal-version: 2.4",
          "name: fake",
          "version: 0.1.0.0",
          "library",
          "  exposed-modules: " <> T.intercalate ", " ("Ops" : modules),
          "  hs-source-dirs: src",
          "  default-language: Haskell2010"
        ]
    modules =
      [ T.pack (takeBaseName path)
      | (path, _) <- files,
        takeExtension path == ".hs"
      ]
    ops =
      T.unlines
        [ "module Ops ((<+>)) where",
          "infixl 6 <+>",
          "(<+>) :: a -> a -> a",
          "(<+>) = const"
        ]
    plan =
      "{\"compiler-id\":\"ghc-0.0\",\"install-plan\":["
        <> T.intercalate
          ","
          ( "{\"pkg-name\":\"fake\",\"pkg-version\":\"0.1.0.0\",\
            \\"pkg-src\":{\"type\":\"local\",\"path\":\"./.\"}}"
              : others
          )
        <> "]}"

-- | Is an environment variable set to anything but empty, @0@, @no@ or
-- @false@?
switchedOn :: String -> IO Bool
switchedOn name =
  maybe False (`notElem` ["", "0", "no", "false"]) <$> lookupEnv name

-- | Run something with files opened in ASCII, as under @LANG=C@, and the
-- locale's encoding as it was afterwards however it turns out.
underAsciiLocale :: IO a -> IO a
underAsciiLocale act = do
  ascii <- mkTextEncoding "ASCII"
  bracket getLocaleEncoding setLocaleEncoding $ \_ ->
    setLocaleEncoding ascii >> act

-- | What to parse a module with, given what its package puts in force, or
-- 'Nothing' for a module of no package, which is read as GHC2021.
parserConfigFor :: Maybe Defaults -> ParserConfig
parserConfigFor =
  ParserConfig . fromMaybe (defaultsOf (GHC.languageExtensions (Just GHC.GHC2021)) [])

-- | What to parse a module of no package with.
defaultParserConfig :: ParserConfig
defaultParserConfig = parserConfigFor Nothing

-- | The extensions in force in a module, given what its package puts in
-- force, or 'Nothing' for a module of no package, which starts from
-- @ImplicitPrelude@ alone.
--
-- That is less than 'parserConfigFor' reads such a module with, so that
-- what is printed, @import qualified@ for one, reads the same under every
-- edition.
effectiveExtensionsFor :: Maybe Defaults -> Text -> [Extension]
effectiveExtensionsFor = effectiveExtensions . fromMaybe (defaultsOf [ImplicitPrelude] [])

-- | Parse a module, or stop the test saying why it does not parse.
parsed :: Text -> ParsedModule
parsed source = case parseModule defaultParserConfig "M.hs" source of
  Left problem -> error (T.unpack (describeParseError problem))
  Right m -> m

-- | Does a module parse, with what its own pragmas turn on?
parses :: Text -> Bool
parses = isRight . parseModule defaultParserConfig "M.hs"

-- | Every file of Haskell source at or under a path, in order.
haskellFilesIn :: FilePath -> IO [FilePath]
haskellFilesIn path = do
  isDir <- doesDirectoryExist path
  if not isDir
    then pure [path | any (`isSuffixOf` path) sourceExtensions]
    else do
      entries <- sort <$> quietly [] (listDirectory path)
      concat <$> traverse (haskellFilesIn . (path </>)) entries

-- | Every comment in a module.
commentsIn :: Text -> [Comment]
commentsIn = comments . pmSource . parsed

-- | The one import blamed for an operator, unread on its own account and so
-- with nothing below it.
unreadOnly :: Text -> NonEmpty ModuleChain
unreadOnly m = ModuleChain (m :| []) :| []

-- | Take fixities that say nothing about namespaces to govern both.
inBothNamespaces :: Map OpName Fixity -> Fixities
inBothNamespaces declared =
  Map.fromList
    [ ((namespace, op), fixity)
    | (op, fixity) <- Map.toList declared,
      namespace <- [InTypes, InTerms]
    ]

-- | FNV-1a, a hash of a string that is the same on every machine and every
-- run.
fingerprint :: String -> Word64
fingerprint = foldl' step 14695981039346656037
  where
    step h c = (h `xor` fromIntegral (ord c)) * 1099511628211

-- | A 'Resolver' that reads modules by the routes given and remembers what
-- it reads.
routedResolver :: [Route] -> BuildPlan -> IO Resolver
routedResolver routes plan = do
  packageReader <- newPackageReader (plannedCompiler plan) []
  newResolverVia (Do #useCache) routes packageReader plan

-- | Split a module on its first outermost conditional, and on every other
-- one written behind the same directives, wherever in the module it sits.
configurations :: Text -> Maybe Configurations
configurations source = do
  forest <- scanConditionals source
  gs <- listToMaybe forest
  pure (configurationsOn gs forest source)

-- | Every configuration of a module, with every conditional resolved.
leaves :: Text -> Either CppError [Text]
leaves = fmap (fmap snd) . answeredLeaves

-- | The configurations reached by varying one conditional at a time.
linearLeaves :: Text -> Either CppError [Text]
linearLeaves = fmap (fmap snd) . answeredLinearLeaves

-- | How many configurations a module has, without building any of them,
-- and so counting those no definition of the macros gives.
countLeaves :: Text -> Either CppError Integer
countLeaves source = do
  forest <- readConditionals source
  pure (sum [across answers forest | answers <- combinations (afforded forest)])
  where
    across answers = product . fmap (one answers)
    one answers gs = case lookup (gsGuards gs) answers of
      Just i -> across answers (nestedIn gs i)
      Nothing -> sum [across answers (nestedIn gs i) | i <- [0 .. gsCount gs - 1]]
    combinations = traverse (\(g, k) -> [(g, i) | i <- [0 .. k - 1]])
    afforded forest = go 1 (repeated forest)
      where
        go _ [] = []
        go n ((g, k) : rest)
          | n * toInteger k <= guardsToTie = (g, k) : go (n * toInteger k) rest
          | otherwise = []

-- | The guards a module asks more than once, and how many answers each has.
repeated :: [GroupSpec] -> [([Guard], Int)]
repeated forest = distinct Map.empty [q | q@(g, _) <- asked forest, twice g]
  where
    asked gss = concat [(gsGuards gs, gsCount gs) : asked (concat (gsNested gs)) | gs <- gss]
    times = Map.fromListWith (+) [(g, 1 :: Int) | (g, _) <- asked forest]
    twice g = Map.findWithDefault 0 g times >= 2

    distinct _ [] = []
    distinct seen (q@(g, _) : rest)
      | Map.member g seen = distinct seen rest
      | otherwise = q : distinct (Map.insert g () seen) rest

-- | How many combinations of answers 'countLeaves' will enumerate.
guardsToTie :: Integer
guardsToTie = 4096

-- | Every configuration some definition of the macros gives, and the
-- answers that reach it.
answeredLeaves :: Text -> Either CppError [(Assignment, Text)]
answeredLeaves =
  fmap (filter (isJust . implied . Map.toList . fst)) . go Map.empty
  where
    go answers source = case configurations source of
      Nothing -> answeredLeaf answers source
      Just c ->
        concat
          <$> traverse
            (\(i, t) -> go (Map.insert (cfgGuards c) i answers) t)
            (zip [0 ..] (cfgTexts c))

-- | The same, labelled by the answers that reach each one, and for the same
-- reason as 'answeredLeaves'.
answeredLinearLeaves :: Text -> Either CppError [(Assignment, Text)]
answeredLinearLeaves =
  fmap (filter (isJust . implied . Map.toList . fst)) . go Map.empty
  where
    go answers source = case configurations source of
      Nothing -> answeredLeaf answers source
      Just c -> case zip [0 ..] (cfgTexts c) of
        [] -> Right []
        (i, first) : rest ->
          (<>)
            <$> go (Map.insert (cfgGuards c) i answers) first
            <*> (concat <$> traverse (held answers (cfgGuards c)) rest)
      where
        held before gs (i, t) = answeredBaseline (Map.insert gs i before) t

-- | The configuration in which every question still to be asked takes its
-- first branch, and the answers that give it, unless it aborts with
-- @#error@.
answeredBaseline :: Assignment -> Text -> Either CppError [(Assignment, Text)]
answeredBaseline answers source = case configurations source of
  Nothing -> answeredLeaf answers source
  Just c -> case cfgTexts c of
    [] -> Right [(answers, source)]
    t : _ -> answeredBaseline (Map.insert (cfgGuards c) 0 answers) t

-- | A module with every conditional answered, and the answers that reach
-- it, unless it aborts with @#error@.
answeredLeaf :: Assignment -> Text -> Either CppError [(Assignment, Text)]
answeredLeaf answers source
  | null (unconditionalErrors source) =
      [(answers, withoutOpaque source)] <$ readConditionals source
  | otherwise = Right []