-- | Converting high-level build matrix concepts into @cabal@ commandlines. module Cabal.Matrix.CabalArgs ( CabalArgs(..) , CabalStep(..) , PerCabalStep(..) , indexCabalStep , tabulateCabalStep' , modifyCabalStep , setCabalStep , CabalMode(..) , Flavor(..) , renderCabalArgs , environmentFilePath ) where import Data.Hashable import Data.List.NonEmpty (NonEmpty(..)) import Data.Set (Set) import Data.Set qualified as Set import Data.Text (Text) import Data.Text qualified as Text import GHC.Generics import System.FilePath -- | Cabal allows interrupting the build process at multiple points, which is -- useful to know which stage the build failed at. data CabalStep = DryRun -- ^ Run with @--dry-run@, only creating a plan. | OnlyDownload -- ^ Run with @--only-download@, creating a plan and only downloading -- dependencies. | OnlyDependencies -- ^ Run with @--only-dependencies@, creating a plan, downloading and -- building only the dependencies. | FullBuild -- ^ Run without any of the aforementioned options, fully building the -- selected targets. deriving stock (Eq, Ord, Show, Enum, Bounded) -- | A collection of values per each 'CabalStep'. Useful for "memoizing" a -- function @'CabalStep' -> a@. data PerCabalStep a = PerCabalStep { dryRun :: a , onlyDownload :: a , onlyDependencies :: a , fullBuild :: a } deriving stock (Functor, Foldable, Traversable) indexCabalStep :: PerCabalStep a -> CabalStep -> a indexCabalStep pcs = \case DryRun -> pcs.dryRun OnlyDownload -> pcs.onlyDownload OnlyDependencies -> pcs.onlyDependencies FullBuild -> pcs.fullBuild -- | Note: the function is evaluated (to WHNF) at each input. tabulateCabalStep' :: (CabalStep -> a) -> PerCabalStep a tabulateCabalStep' f = PerCabalStep{..} where !dryRun = f DryRun !onlyDownload = f OnlyDownload !onlyDependencies = f OnlyDependencies !fullBuild = f FullBuild modifyCabalStep :: CabalStep -> (a -> a) -> PerCabalStep a -> PerCabalStep a modifyCabalStep step f pcs = case step of DryRun | !dryRun <- f pcs.dryRun -> pcs { dryRun } OnlyDownload | !onlyDownload <- f pcs.onlyDownload -> pcs { onlyDownload } OnlyDependencies | !onlyDependencies <- f pcs.onlyDependencies -> pcs { onlyDependencies } FullBuild | !fullBuild <- f pcs.fullBuild -> pcs { fullBuild } setCabalStep :: CabalStep -> a -> PerCabalStep a -> PerCabalStep a setCabalStep step value pcs = case step of DryRun -> pcs { dryRun = value } OnlyDownload -> pcs { onlyDownload = value } OnlyDependencies -> pcs { onlyDependencies = value } FullBuild -> pcs { fullBuild = value } -- | In either case if we are in a cabal project then we can only install -- packages that are in the dependency closure of the project, and they will be -- subject to constraints defined in the project file, if any. data CabalMode = ProjectBuild -- | Assume that we're in a cabal project and run @cabal build@ -- targeting the project's packages and their dependencies. | InstallLib -- | Use @cabal install --lib@, which doesn't require to be in a -- cabal project. If we are in a project, the project's packages will be -- sdisted first. -- | Options defining what cell in the build matrix we're in. data Flavor = Flavor { unorderedOptions :: Set Text , orderedOptions :: [Text] } deriving stock (Eq, Show, Generic) deriving anyclass (Hashable) instance Semigroup Flavor where f1 <> f2 = Flavor { unorderedOptions = Set.union f1.unorderedOptions f2.unorderedOptions , orderedOptions = f1.orderedOptions ++ f2.orderedOptions } instance Monoid Flavor where mempty = Flavor { unorderedOptions = Set.empty , orderedOptions = [] } -- | A single invocation of @cabal@. data CabalArgs = CabalArgs { cabalExecutable :: FilePath , mode :: CabalMode , step :: CabalStep , options :: [Text] , targets :: [Text] , flavor :: Flavor } data CabalRawArgs = CabalRawArgs { cabalExecutable :: FilePath , buildDir :: FilePath -- ^ @--builddir@, where build artifacts will be placed. Different -- flavors using the same compiler must use different 'buildDir'. , mode :: CabalMode , envFile :: Maybe FilePath -- ^ An environment file to use with @install --lib@. The file needs to not -- yet exist, so that its contents don't conflict with the install plan, but -- running cabal will bring this file into existence. So we basically have -- to remove this file every time. , step :: CabalStep , options :: [Text] , targets :: [Text] } renderRawCabalArgs :: CabalRawArgs -> NonEmpty Text renderRawCabalArgs ca = "cabal" :| mconcat [ case ca.mode of ProjectBuild -> ["build"] InstallLib -> ["install", "--lib"] , case ca.envFile of Nothing -> [] Just path -> ["--package-env", Text.pack path] , ["--builddir", Text.pack ca.buildDir] , case ca.step of DryRun -> ["--dry-run"] OnlyDownload -> ["--only-download"] OnlyDependencies -> ["--only-dependencies"] FullBuild -> [] , ca.options , ["--" | not $ null ca.targets] , ca.targets ] argsToRaw :: CabalArgs -> CabalRawArgs argsToRaw args@CabalArgs{..} = CabalRawArgs { cabalExecutable , buildDir = buildDirFor flavor , mode , envFile = environmentFilePath args , step , options = concat [ options , Set.toList flavor.unorderedOptions , flavor.orderedOptions ] , targets } -- TODO: there probably needs to be some mechanism to clean up these at some -- point. buildDirFor :: Flavor -> FilePath buildDirFor f = "dist-newstyle" ("cabal-matrix-" <> show (fromIntegral @Int @Word $ hash f)) renderCabalArgs :: CabalArgs -> NonEmpty Text renderCabalArgs = renderRawCabalArgs . argsToRaw environmentFilePath :: CabalArgs -> Maybe FilePath environmentFilePath CabalArgs{..} = case mode of ProjectBuild -> Nothing InstallLib -> Just $ buildDirFor flavor "env"