tilia-0.1.1.0: tests/Tilia/WithProjectPlan.hs
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedLabels #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternSynonyms #-}
-- | This project, prepared the way the specs that read it need it.
module Tilia.WithProjectPlan
( withProjectPlan,
compilerShipped,
Dependency (..),
dependenciesOf,
testsFor,
)
where
import Control.Monad (filterM, unless)
import Data.Choice (pattern Do, pattern Don't)
import Data.Maybe (isNothing, listToMaybe, mapMaybe)
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import System.Directory (doesFileExist)
import System.FilePath (takeDirectory, (</>))
import System.IO.Unsafe (unsafePerformIO)
import Test.Hspec (Spec)
import Tilia.Cabal.Project (findProjectRoot)
import Tilia.Cabal.Target
( Target (..),
componentInPlan,
componentsOfTarget,
describeTargetProblem,
)
import Tilia.Fixity.Cache (PlanToken (..), openCache)
import Tilia.Fixity.PackageDb
( Installed (..),
InstalledPackage (..),
readInstalledPackages,
)
import Tilia.Fixity.Plan
( BuildPlan (..),
PackageSource (..),
PlanPackage (..),
loadPlan,
plannedTarballs,
)
import Tilia.Process (readProgramOutput)
-- | Build a spec around the build plan of this very project, with the
-- package sources that spec reads alongside it already fetched.
withProjectPlan :: (BuildPlan -> Spec) -> Spec
withProjectPlan use = either refuse use prepared
where
refuse why = error ("this project will not prepare: " <> T.unpack why)
-- | The answer 'withProjectPlan' hands out, worked out at most once.
prepared :: Either Text BuildPlan
prepared = unsafePerformIO prepare
{-# NOINLINE prepared #-}
-- | Find the project, have @cabal@ make it ready, and fetch what the specs
-- read that being ready does not cover.
prepare :: IO (Either Text BuildPlan)
prepare =
findProjectRoot "." >>= \case
Nothing -> pure (Left "no cabal.project or .cabal file above this directory")
Just root ->
componentsOfTarget root Everything >>= \case
Left problem -> pure (Left (describeTargetProblem problem))
Right components ->
loadPlan (Do #useCache) (Do #download) (mapMaybe componentInPlan components) "." >>= \case
Left why -> pure (Left why)
Right plan -> Right plan <$ fetchMissingSources plan
-- | Fetch the source of every planned package the specs read that is not
-- already here.
fetchMissingSources :: BuildPlan -> IO ()
fetchMissingSources plan = do
installed <- readInstalledPackages
noCache <- openCache (Don't #useCache) (PlanToken "")
let unread = modulelessIn installed
absent <-
filterM (fmap not . doesFileExist . snd)
. filter (not . (`Set.member` unread) . ppName . fst)
=<< plannedTarballs noCache plan
unless (null absent) $ do
putStrLn ("fetching the source of " <> show (length absent) <> " package(s)")
refused <- filterM (fmap isNothing . fetch . fst) absent
unless (null refused) $
putStrLn ("no source on Hackage for " <> unwords (fmap (named . fst) refused))
where
fetch p = readProgramOutput "cabal" ["fetch", "--no-dependencies", named p]
named p = T.unpack (ppName p <> "-" <> ppVersion p)
-- | The packages that came with the compiler rather than from a release of
-- their own, by the directory they are installed beside @ghc@ in.
compilerShipped :: IO (Set Text)
compilerShipped = shippedIn <$> readInstalledPackages
-- | The same, of packages already read.
shippedIn :: Installed -> Set Text
shippedIn installed = case compilerDir of
Nothing -> Set.empty
Just dir ->
Set.fromList [ipName p | p <- installedPackages installed, beside p == Just dir]
where
beside p = takeDirectory <$> listToMaybe (ipImportDirs p)
compilerDir =
listToMaybe
[dir | p <- installedPackages installed, ipName p == "ghc", Just dir <- [beside p]]
-- | Packages holding no modules at all, such as the runtime system.
modulelessIn :: Installed -> Set Text
modulelessIn installed =
Set.fromList [ipName p | p <- installedPackages installed, null (ipModules p)]
-- | A package this project is built against, as the compiler holds it.
data Dependency = Dependency
{ -- | The package name
depPackage :: Text,
-- | Each module the package holds, with the interface file the compiler
-- wrote for it.
depModules :: [(Text, FilePath)]
}
-- | The modules of every package in the plan that the compiler can also
-- see, keeping the ones the predicate wants.
dependenciesOf :: (Text -> Bool) -> BuildPlan -> Installed -> IO [Dependency]
dependenciesOf wanted plan installed =
filter (not . null . depModules) <$> traverse ofPackage candidates
where
candidates =
[ (package, dir)
| package <- installedPackages installed,
Set.member (ipName package) planned,
dir <- take 1 (ipImportDirs package)
]
ofPackage (package, dir) =
Dependency (ipName package)
<$> filterM
(doesFileExist . snd)
[ (m, dir </> T.unpack (T.replace "." "/" m) <> ".hi")
| m <- ipModules package,
wanted m
]
planned = Set.fromList [ppName p | p <- bpPackages plan, not (isLocal p)]
isLocal p = case ppSource p of
LocalPackage _ -> True
_ -> False
-- | One test per package, splitting the large ones up.
testsFor :: Dependency -> [(String, [(Text, FilePath)])]
testsFor dependency = case chunksOf 32 (depModules dependency) of
[whole] -> [(name, whole)]
pieces ->
[ (name <> " (" <> show i <> " of " <> show (length pieces) <> ")", piece)
| (i, piece) <- zip [1 :: Int ..] pieces
]
where
name = T.unpack (depPackage dependency)
chunksOf :: Int -> [a] -> [[a]]
chunksOf n = \case
[] -> []
xs -> let (chunk, rest) = splitAt n xs in chunk : chunksOf n rest