build-env (empty) → 1.0.0.0
raw patch · 13 files changed
+4173/−0 lines, 13 filesdep +aesondep +asyncdep +base
Dependencies added: aeson, async, base, build-env, bytestring, containers, directory, filepath, optparse-applicative, process, temporary, text, time, transformers
Files
- app/BuildEnv/Options.hs +142/−0
- app/BuildEnv/Parse.hs +486/−0
- app/Main.hs +187/−0
- build-env.cabal +125/−0
- changelog.md +9/−0
- readme.md +263/−0
- src/BuildEnv/Build.hs +724/−0
- src/BuildEnv/BuildOne.hs +569/−0
- src/BuildEnv/CabalPlan.hs +458/−0
- src/BuildEnv/Config.hs +372/−0
- src/BuildEnv/File.hs +152/−0
- src/BuildEnv/Script.hs +433/−0
- src/BuildEnv/Utils.hs +253/−0
+ app/BuildEnv/Options.hs view
@@ -0,0 +1,142 @@+{-# LANGUAGE DataKinds #-} + +-- | +-- Module : BuildEnv.Options +-- Description : Options for the command-line interface of @build-env@ +-- +-- This module declares datatypes which specify the structure of the options +-- expected by the command-line interface of @build-env@. +module BuildEnv.Options where + +-- containers +import Data.Set + ( Set ) + +-- build-env +import BuildEnv.CabalPlan +import BuildEnv.Config + +-------------------------------------------------------------------------------- + +-- | The command-line options for the @build-env@ application. +data Opts = Opts { compiler :: Compiler + , cabal :: Cabal + , mode :: Mode + , verbosity :: Verbosity + , delTemp :: TempDirPermanence + , workDir :: FilePath + } + +-- | The mode in which to run the executable: +-- +-- - compute a build plan, +-- - fetch sources, +-- - build and register packages. +data Mode + -- | Find a build plan. + = PlanMode + { planModeInputs :: PlanInputs + , planOutput :: FilePath + -- ^ Where to output the @plan.json@ file. + } + -- | Fetch sources from a build plan. + | FetchMode + FetchDescription -- ^ what to fetch + NewOrExisting -- ^ whether to create a new directory + -- or add to an existing one + -- | Build and register packages from fetched sources. + | BuildMode Build + +-- | How to specify which packages/units to constraint/build. +data PackageData pkgs + -- | Explicit description of packages/units. + = Explicit pkgs + -- | Parse package information from the given file. + -- + -- The file contents will be interpreted as follows: + -- + -- - pinned packages: this is a @cabal.config@ freeze file, + -- which uses @cabal.project@ syntax. See 'readCabalDotConfig'. + -- + -- - seed units: this is a list of seed units to build, + -- with inline flags and constraints, and allow-newer stanzas. + -- See 'parseSeedFile'. + | FromFile FilePath + deriving stock Show + +-- | Inputs for the computation of a cabal plan. +data PlanInputs + = PlanInputs + { planUnits :: PackageData UnitSpecs + -- ^ Seed dependencies for the build plan. + , planPins :: Maybe (PackageData PkgSpecs) + -- ^ Additional package constraints. + , planAllowNewer :: AllowNewer + -- ^ Allow-newer specification. + } + deriving stock Show + +-- | Information about fetched sources: in which directory they belong, +-- and what build plan they correspond to. +data FetchDescription + = FetchDescription + { rawFetchDir :: FilePath + -- ^ Directory for fetched sources. + , fetchInputPlan :: Plan + -- ^ The build plan corresponding to the fetched sources. + } + deriving stock Show + +-- | How to obtain a plan: either by computing it, +-- or by using an existing @plan.json@ plan. +data Plan + -- | Compute a plan. + = ComputePlan + PlanInputs + -- ^ Input needed to compute the plan. + (Maybe FilePath) + -- ^ Optional filepath at which to write out the computed plan. + + -- | Use an existing @plan.json@ by reading the given file. + | UsePlan + { planJSONPath :: FilePath } + deriving stock Show + +-- | At which point to start/continue a build. +data BuildStart + -- | Fetch sources and start a new build. + = Fetch NewOrExisting + -- | Start a new build from prefetched sources. + | Prefetched + -- | Resume a previous build. + | Resume + deriving stock ( Show, Eq, Ord ) + +-- | Whether to create a new directory or use an existing directory. +data NewOrExisting + -- | Create a new directory. + = New + -- | Update an existing directory. + | Existing + deriving stock ( Show, Eq, Ord ) + +-- | Information needed to perform a build. +data Build + = Build + { buildRawPaths :: Paths Raw + -- ^ The directories relevant for the build: + -- + -- - fetched sources directory, + -- - build output directory structure + , buildStart :: BuildStart + -- ^ At which stage should the build start? + , buildBuildPlan :: Plan + -- ^ The build plan to follow. + , buildStrategy :: BuildStrategy + -- ^ How to perform the build (see 'BuildStrategy'). + , mbOnlyDepsOf :: Maybe ( Set PkgName ) + -- ^ @Just pkgs@ <=> only build @pkgs@ (and their dependencies). + -- @Nothing@ <=> build all units in the build plan. + , userUnitArgs :: ConfiguredUnit -> UnitArgs + -- ^ Extra per-unit arguments. + }
+ app/BuildEnv/Parse.hs view
@@ -0,0 +1,486 @@+ +{-# LANGUAGE ApplicativeDo #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE TupleSections #-} + +-- | +-- Module : BuildEnv.Parse +-- Description : Command-line option parser for @build-env@ +-- +-- This module implements the command-line options parsing for @build-env@. +module BuildEnv.Parse ( options, runOptionsParser ) where + +-- base +import Data.Char + ( isSpace ) +import Data.Bool + ( bool ) +import System.Environment + ( getArgs ) + +-- containers +import qualified Data.Map.Strict as Map + ( empty, fromList, singleton ) +import Data.Set + ( Set ) +import qualified Data.Set as Set + ( fromList, singleton ) + +-- optparse-applicative +import Options.Applicative + +-- text +import Data.Text + ( Text ) +import qualified Data.Text as Text + ( break, pack, splitOn, unpack ) + +-- build-env +import BuildEnv.CabalPlan +import BuildEnv.Config +import BuildEnv.Options +import BuildEnv.Utils + ( splitOn ) + +-------------------------------------------------------------------------------- + +-- | Run the command-line options parser. +runOptionsParser :: FilePath -> IO Opts +runOptionsParser currWorkDir = do + args <- getArgs + handleParseResult $ execParserPure pPrefs pInfo args + where + pInfo = + info ( helper <*> options currWorkDir ) + ( fullDesc + <> header "build-env - compute, fetch and build Cabal build plans" ) + pPrefs = prefs $ + mconcat [ showHelpOnEmpty + , subparserInline + , helpShowGlobals + , multiSuffix "*" + , columns 90 ] + +-- | The command-line options parser for the 'build-env' executable. +options :: FilePath -> Parser Opts +options currWorkDir = do + mode <- optMode + compiler <- optCompiler + cabal <- optCabal + workDir <- optChangeWorkingDirectory currWorkDir + verbosity <- optVerbosity + delTemp <- optTempDirPermanence + pure $ Opts { compiler, cabal, mode, verbosity, delTemp, workDir } + +-- | Parse @ghc@ and @ghc-pkg@ paths. +optCompiler :: Parser Compiler +optCompiler = + Compiler + <$> option str + ( long "ghc" + <> value "ghc" + <> help "'ghc' executable path" + <> metavar "GHC" + ) + <*> option str + ( long "ghc-pkg" + <> value "ghc-pkg" + <> help "'ghc-pkg' executable path" + <> metavar "GHC-PKG" + ) + +-- | Parse @cabal@ path. +optCabal :: Parser Cabal +optCabal = do + cabalPath <- + option str + ( long "cabal" + <> value "cabal" + <> help "'cabal' executable path" + <> metavar "CABAL" ) + globalCabalArgs <- + many $ option str ( long "cabal-arg" + <> help "Pass argument to 'cabal'" + <> metavar "ARG" ) + pure $ Cabal { cabalPath, globalCabalArgs } + +-- | Parse a @cwd@ (change working directory) option. +optChangeWorkingDirectory :: FilePath -> Parser FilePath +optChangeWorkingDirectory currWorkDir = + option str + ( long "cwd" + <> value currWorkDir + <> help "Set working directory" + <> metavar "DIR" ) + +-- | Parse verbosity. +optVerbosity :: Parser Verbosity +optVerbosity = + Verbosity <$> + option auto + ( long "verbosity" + <> short 'v' + <> help "Verbosity" + <> metavar "INT" + <> value 1 ) + +-- | Parse whether to delete temporary directories. +optTempDirPermanence :: Parser TempDirPermanence +optTempDirPermanence = + bool DeleteTempDirs Don'tDeleteTempDirs <$> + switch ( long "preserve-tmp" + <> help "Preserve temporary build directories (useful for debugging)" ) + +-- | Parse the mode in which to run the application: plan, fetch, build. +optMode :: Parser Mode +optMode = + hsubparser . mconcat $ + [ command "plan" $ + info ( PlanMode <$> planInputs Planning <*> optOutput ) + ( fullDesc <> planInfo ) + , command "fetch" $ + info ( FetchMode <$> fetchDescription <*> newOrExisting ) + ( fullDesc <> fetchInfo ) + , command "build" $ + info ( BuildMode <$> build ) + ( fullDesc <> buildInfo ) + ] + where + optOutput :: Parser FilePath + optOutput = + option str ( short 'p' + <> long "plan" + <> help "Output 'plan.json' file" + <> metavar "OUTFILE" + ) + planInfo, fetchInfo, buildInfo :: InfoMod a + planInfo = progDesc "Compute a build plan from a collection of seeds" + fetchInfo = progDesc "Fetch package sources" + buildInfo = progDesc "Build and register packages" + +-- | Description of which mode we are in. +-- +-- Used to generate help-text that is specific to a certain mode. +data ModeDescription + = Planning + | Fetching + | Building + deriving stock Show + +-- | Text describing a mode of the @build-env@ executable. +modeDescription :: ModeDescription -> String +modeDescription modeDesc = + case modeDesc of + Planning -> "Build plan" + Fetching -> "Fetch plan" + Building -> "Build" + +-- | Obtain a collection of seed packages. +-- +-- Might be for computing a build plan, fetching sources, or building packages. +planInputs :: ModeDescription -> Parser PlanInputs +planInputs modeDesc = do + + planPins <- optional (freeze modeDesc) + planUnits <- dependencies modeDesc + planAllowNewer <- allowNewer + + pure $ PlanInputs { planPins, planUnits, planAllowNewer } + +-- | Parse a list of pinned packages from a 'cabal.config' freeze file. +freeze :: ModeDescription -> Parser ( PackageData PkgSpecs ) +freeze modeDesc = FromFile <$> freezeFile + where + freezeFile :: Parser FilePath + freezeFile = option str ( long "freeze" <> help helpStr <> metavar "INFILE" ) + + helpStr :: String + helpStr = modeDescription modeDesc <> " 'cabal.config' freeze file" + +-- | Parse @allow-newer@ options. +allowNewer :: Parser AllowNewer +allowNewer = + fmap mconcat . many $ + option readAllowNewer + ( long "allow-newer" <> help "Allow-newer specification" + <> metavar "PKG1:PKG2" ) + where + readAllowNewer :: ReadM AllowNewer + readAllowNewer = do + allowNewerString <- str + case parseAllowNewer allowNewerString of + Just an -> return an + Nothing -> + readerError $ + "Invalid allow-newer specification.\n" ++ + "Should be of the form: pkg1:pkg2,*:base,..." + + parseAllowNewer :: String -> Maybe AllowNewer + parseAllowNewer = fmap ( AllowNewer . Set.fromList ) + . traverse oneAllowNewer + . splitOn ',' + + oneAllowNewer :: String -> Maybe (Text, Text) + oneAllowNewer s + | (Text.pack -> a, Text.pack . drop 1 -> b) <- break (== ':') s + , a == "*" || validPackageName a + , b == "*" || validPackageName b + = Just (a,b) + | otherwise + = Nothing + +-- | Parse a collection of seed dependencies, either from a seed file +-- or from explicit command-line arguments. +dependencies :: ModeDescription -> Parser ( PackageData UnitSpecs ) +dependencies modeDesc + = ( FromFile <$> seeds ) + <|> explicitUnits + + where + + seeds :: Parser FilePath + seeds = option str ( long "seeds" <> help seedsHelp <> metavar "INFILE" ) + + explicitUnits :: Parser ( PackageData UnitSpecs ) + explicitUnits = do + units <- some ( argument readUnitSpec (metavar "UNIT" <> help unitsHelp) ) + locals <- many localPkg + pure $ Explicit $ + foldl unionUnitSpecsCombining Map.empty units + `unionUnitSpecsCombining` + Map.fromList + [ (pkg, (Local loc, emptyPkgSpec, mempty)) + | (pkg, loc) <- locals ] + + localPkg :: Parser (PkgName, FilePath) + localPkg = + option readLocalPkg + ( long "local" + <> help "Local package source location" + <> metavar "PKG=PATH" ) + + readLocalPkg :: ReadM (PkgName, FilePath) + readLocalPkg = do + ln <- str + case Text.splitOn "=" ln of + pkg:loc:_ + | validPackageName pkg + -> return (PkgName pkg, Text.unpack loc) + _ -> readerError $ + "Could not parse --local argument\n" ++ + "Valid usage is of the form: --local PKG=PATH" + + readUnitSpec :: ReadM UnitSpecs + readUnitSpec = do + ln <- str + let (pkgTyComp, rest) = Text.break isSpace ln + spec = parsePkgSpec rest + case parsePkgComponent pkgTyComp of + Nothing -> readerError $ "Cannot parse package name: " <> Text.unpack ln + Just (pkgNm, comp) -> + return $ Map.singleton pkgNm (Remote, spec, Set.singleton comp) + + unitsHelp, seedsHelp :: String + (unitsHelp, seedsHelp) = (what <> " seed unit", what <> " seed file") + where + what = modeDescription modeDesc + +-- | Parse how we will obtain a build plan: by computing it, or by reading +-- from a @plan.json@ on disk? +plan :: ModeDescription -> Parser Plan +plan modeDesc = ( UsePlan <$> optPlanPath ) + <|> ( ComputePlan <$> planInputs modeDesc <*> optPlanOutput ) + + where + optPlanPath :: Parser FilePath + optPlanPath = + option str + ( short 'p' + <> long "plan" + <> help "Input 'plan.json' file" + <> metavar "INFILE" ) + + optPlanOutput :: Parser (Maybe FilePath) + optPlanOutput = + option (fmap Just str) + ( long "output-plan" + <> value Nothing + <> help "Output 'plan.json' file" + <> metavar "OUTFILE" ) + +-- | Parse information about fetched sources: in which directory they belong, +-- and what build plan they correspond to. +fetchDescription :: Parser FetchDescription +fetchDescription = do + fetchInputPlan <- plan Fetching + rawFetchDir <- optFetchDir Fetching + pure $ FetchDescription { rawFetchDir, fetchInputPlan } + +-- | Parse the fetch directory. +optFetchDir :: ModeDescription -> Parser FilePath +optFetchDir modeDesc = + option str + ( short 'f' + <> long "fetchdir" + <> help "Directory for fetched sources" + <> metavar metavarStr ) + where + metavarStr :: String + metavarStr = case modeDesc of + Building -> "INDIR" + _ -> "OUTDIR" + +-- | Parse whether to create a new fetch directory or update an existing one. +newOrExisting :: Parser NewOrExisting +newOrExisting = + bool New Existing <$> + switch ( long "update" + <> help "Update existing fetched sources directory" ) + +-- | Parse the options for the @build@ command. +build :: Parser Build +build = do + + buildBuildPlan <- plan Building + buildStart <- optStart + buildStrategy <- optStrategy + buildRawPaths <- optRawPaths + userUnitArgs <- optUnitArgs + mbOnlyDepsOf <- optOnlyDepsOf + + pure $ + Build { buildStart + , buildBuildPlan + , buildStrategy + , buildRawPaths + , userUnitArgs + , mbOnlyDepsOf } + + where + + optStrategy :: Parser BuildStrategy + optStrategy = optScript <|> ( Execute <$> optRunStrat ) + + optRunStrat :: Parser RunStrategy + optRunStrat = ( Async <$> asyncSem ) <|> pure TopoSort + + asyncSem :: Parser AsyncSem + asyncSem = + option (fmap NewQSem auto <|> pure NoSem) + ( short 'j' + <> long "async" + <> help "Use asynchronous package building" + <> metavar "N" ) + + optScript :: Parser BuildStrategy + optScript = do + scriptPath <- optScriptPath + useVariables <- + switch + ( long "variables" + <> help "Use variables in the shell script output ($PREFIX etc)" ) + pure $ Script { scriptPath, useVariables } + + optScriptPath :: Parser FilePath + optScriptPath = + option str + ( long "script" + <> help "Output a shell script containing build steps" + <> metavar "OUTFILE" ) + + optStart :: Parser BuildStart + optStart = + resume <|> prefetched <|> ( Fetch <$> newOrExisting ) + + resume :: Parser BuildStart + resume = + flag' Resume + ( long "resume" + <> help "Resume a partially-completed build" ) + + prefetched :: Parser BuildStart + prefetched = + flag' Prefetched + ( long "prefetched" + <> help "Start the build from the prefetched sources" ) + + optRawPaths :: Parser (Paths Raw) + optRawPaths = do + fetchDir <- optFetchDir Building + buildPaths <- optBuildPaths + return $ Paths { fetchDir, buildPaths } + + optBuildPaths :: Parser (BuildPaths Raw) + optBuildPaths = do + rawPrefix <- + option str ( short 'o' + <> long "prefix" + <> help "Installation prefix" + <> metavar "OUTDIR" ) + rawDestDir <- + option str ( long "destdir" + <> help "Installation destination directory" + <> value "/" + <> metavar "OUTDIR" ) + pure $ RawBuildPaths { rawPrefix, rawDestDir } + + -- TODO: we only support passing arguments for all units at once, + -- rather than per-unit. + optUnitArgs :: Parser ( ConfiguredUnit -> UnitArgs ) + optUnitArgs = do + confArgs <- optConfigureArgs + mbDocArgs <- optHaddockArgs + regArgs <- optGhcPkgArgs + pure $ \ cu -> + UnitArgs { configureArgs = confArgs cu + , mbHaddockArgs = fmap ($ cu) mbDocArgs + , registerArgs = regArgs cu } + + optConfigureArgs :: Parser ( ConfiguredUnit -> Args ) + optConfigureArgs = do + args <- many $ option str ( long "configure-arg" + <> help "Pass argument to 'Setup configure'" + <> metavar "ARG" ) + pure $ const args + + optHaddockArgs :: Parser ( Maybe ( ConfiguredUnit -> Args ) ) + optHaddockArgs = do + doHaddock <- + switch ( long "haddock" + <> help "Generate haddock documentation" ) + args <- many $ + option str ( long "haddock-arg" + <> help "Pass argument to 'Setup haddock'" + <> metavar "ARG" ) + pure $ + if null args && not doHaddock + then Nothing + else Just $ const args + + optGhcPkgArgs :: Parser ( ConfiguredUnit -> Args ) + optGhcPkgArgs = do + args <- many $ + option str ( long "ghc-pkg-arg" + <> help "Pass argument to 'ghc-pkg register'" + <> metavar "ARG" ) + pure $ const args + + optOnlyDepsOf :: Parser ( Maybe ( Set PkgName ) ) + optOnlyDepsOf = do + pkgs <- many $ + option pkgName + ( long "only" + <> help "Only build these packages (and their dependencies)" + <> metavar "PKG" ) + pure $ + if null pkgs + then Nothing + else Just $ Set.fromList pkgs + + pkgName :: ReadM PkgName + pkgName = do + nm <- str + if validPackageName nm + then return $ PkgName nm + else readerError $ + "Invalid package name: " <> Text.unpack nm
+ app/Main.hs view
@@ -0,0 +1,187 @@+module Main ( main ) where + +-- base +import Control.Monad + ( guard ) +import Data.Foldable + ( for_ ) +import System.IO + ( BufferMode(..), hSetBuffering, stdout ) + +-- bytestring +import qualified Data.ByteString.Lazy as Lazy.ByteString + ( readFile, writeFile ) + +-- containers +import qualified Data.Map as Map + ( empty ) +import qualified Data.Set as Set + ( empty, member ) + +-- directory +import System.Directory + +-- text +import qualified Data.Text as Text + ( pack ) + +-- build-env +import BuildEnv.Build +import BuildEnv.CabalPlan +import BuildEnv.Config +import BuildEnv.File + ( parseCabalDotConfigPkgs, parseSeedFile ) +import BuildEnv.Options +import BuildEnv.Parse + ( runOptionsParser ) + +-------------------------------------------------------------------------------- + +main :: IO () +main = do + currentDir <- getCurrentDirectory + Opts { compiler, cabal, mode, verbosity, delTemp, workDir } + <- runOptionsParser currentDir + hSetBuffering stdout $ BlockBuffering Nothing + withCurrentDirectory workDir $ + case mode of + PlanMode { planModeInputs, planOutput } -> do + CabalPlanBinary planBinary <- + computePlanFromInputs delTemp verbosity workDir compiler cabal planModeInputs + normalMsg verbosity $ + "Writing build plan to '" <> Text.pack planOutput <> "'" + Lazy.ByteString.writeFile planOutput planBinary + FetchMode ( FetchDescription { rawFetchDir, fetchInputPlan } ) newOrUpd -> do + plan <- getPlan delTemp verbosity workDir compiler cabal fetchInputPlan + fetchDir <- canonicalizePath rawFetchDir + doFetch verbosity cabal fetchDir True newOrUpd plan + BuildMode ( Build { buildBuildPlan + , buildStart + , buildStrategy + , buildRawPaths = rawPaths + , mbOnlyDepsOf + , userUnitArgs } ) -> do + plan <- getPlan delTemp verbosity workDir compiler cabal buildBuildPlan + ( pathsForPrep@( Paths { fetchDir }), pathsForBuild ) + <- canonicalizePaths compiler buildStrategy rawPaths + case buildStart of + Fetch newOrUpd -> doFetch verbosity cabal fetchDir False newOrUpd plan + _ -> return () + let mbOnlyDepsOfUnits :: Maybe [UnitId] + mbOnlyDepsOfUnits = + case mbOnlyDepsOf of + Nothing -> Nothing + Just pkgs -> + let wantUnit :: PlanUnit -> Maybe UnitId + wantUnit pu = do + ConfiguredUnit { puPkgName, puId } <- configuredUnitMaybe pu + guard ( puPkgName `Set.member` pkgs ) + return puId + in Just $ mapMaybePlanUnits wantUnit plan + + let resumeBuild = case buildStart of { Resume -> True; _ -> False } + buildPlan verbosity workDir pathsForPrep pathsForBuild + buildStrategy resumeBuild mbOnlyDepsOfUnits userUnitArgs plan + +-- | Generate the contents of @pkg.cabal@ and @cabal.project@ files, using +-- +-- - a seed file containing packages to build (with constraints, flags +-- and allow-newer), +-- - a @cabal.config@ freeze file, +-- - explicit packages and allow-newer specified as command-line arguments. +parsePlanInputs :: Verbosity -> FilePath -> PlanInputs -> IO CabalFilesContents +parsePlanInputs verbosity workDir (PlanInputs { planPins, planUnits, planAllowNewer }) + = do (pkgs, fileAllowNewer) <- parsePlanUnits verbosity planUnits + let + allAllowNewer = fileAllowNewer <> planAllowNewer + -- NB: allow-newer specified in the command-line overrides + -- the allow-newer included in the seed file. + cabalContents = cabalFileContentsFromPackages pkgs + projectContents <- + case planPins of + Nothing -> return $ cabalProjectContentsFromPackages workDir pkgs Map.empty allAllowNewer + Just (FromFile pinCabalConfig) -> do + normalMsg verbosity $ + "Reading 'cabal.config' file at '" <> Text.pack pinCabalConfig <> "'" + pins <- parseCabalDotConfigPkgs pinCabalConfig + return $ cabalProjectContentsFromPackages workDir pkgs pins allAllowNewer + Just (Explicit pins) -> do + return $ cabalProjectContentsFromPackages workDir pkgs pins allAllowNewer + return $ CabalFilesContents { cabalContents, projectContents } + +-- | Retrieve the seed units we want to build, either from a seed file +-- or from explicit command line arguments. +parsePlanUnits :: Verbosity -> PackageData UnitSpecs -> IO (UnitSpecs, AllowNewer) +parsePlanUnits _ (Explicit units) = return (units, AllowNewer Set.empty) +parsePlanUnits verbosity (FromFile fp) = + do normalMsg verbosity $ + "Reading seed packages from '" <> Text.pack fp <> "'" + parseSeedFile fp + +-- | Compute a build plan by calling @cabal build --dry-run@ with the generated +-- @pkg.cabal@ and @cabal.project@ files. +computePlanFromInputs :: TempDirPermanence + -> Verbosity + -> FilePath + -> Compiler + -> Cabal + -> PlanInputs + -> IO CabalPlanBinary +computePlanFromInputs delTemp verbosity workDir comp cabal inputs + = do cabalFileContents <- parsePlanInputs verbosity workDir inputs + normalMsg verbosity "Computing build plan" + computePlan delTemp verbosity comp cabal cabalFileContents + +-- | Retrieve a cabal build plan, either by computing it or using +-- a pre-existing @plan.json@ file. +getPlan :: TempDirPermanence -> Verbosity -> FilePath -> Compiler -> Cabal -> Plan -> IO CabalPlan +getPlan delTemp verbosity workDir comp cabal planMode = do + planBinary <- + case planMode of + ComputePlan planInputs mbPlanOutputPath -> do + plan@(CabalPlanBinary planData) <- + computePlanFromInputs delTemp verbosity workDir comp cabal planInputs + for_ mbPlanOutputPath \ planOutputPath -> + Lazy.ByteString.writeFile planOutputPath planData + return plan + UsePlan { planJSONPath } -> + do + normalMsg verbosity $ + "Reading build plan from '" <> Text.pack planJSONPath <> "'" + CabalPlanBinary <$> Lazy.ByteString.readFile planJSONPath + return $ parsePlanBinary planBinary + +-- | Fetch all packages in a cabal build plan. +doFetch :: Verbosity + -> Cabal + -> FilePath + -> Bool -- ^ True <=> we are fetching (not building) + -- (only relevant for error messages) + -> NewOrExisting + -> CabalPlan + -> IO () +doFetch verbosity cabal fetchDir0 weAreFetching newOrUpd plan = do + fetchDir <- canonicalizePath fetchDir0 + fetchDirExists <- doesDirectoryExist fetchDir + case newOrUpd of + New | fetchDirExists -> + error $ unlines $ + "Fetch directory already exists." : existsMsg + ++ [ "Fetch directory: " <> fetchDir ] + Existing | not fetchDirExists -> + error $ unlines + [ "Fetch directory must already exist when using --update." + , "Fetch directory: " <> fetchDir ] + _ -> return () + createDirectoryIfMissing True fetchDir + normalMsg verbosity $ + "Fetching sources from build plan into directory '" <> Text.pack fetchDir <> "'" + fetchPlan verbosity cabal fetchDir plan + + where + existsMsg + | weAreFetching + = [ "Use --update to update an existing directory." ] + | otherwise + = [ "Use --prefetched to build using a prefetched source directory," + , "or --update to continue fetching before building." ]
+ build-env.cabal view
@@ -0,0 +1,125 @@+cabal-version: 3.0 +name: build-env +version: 1.0.0.0 +author: Ben Gamari & Sam Derbyshire +maintainer: ben@smart-cactus.org +license: BSD-3-Clause +category: Distribution +build-type: Simple +homepage: https://github.com/bgamari/build-env +bug-reports: https://github.com/bgamari/build-env/issues + +extra-source-files: + readme.md + changelog.md + +synopsis: Compute, fetch and install Cabal build plans into a local environment + +description: + __build-env__ allows one to compute, fetch and install Cabal build plans + into a local environment, registering the libraries into a free-standing + package database. + + In particular, __build-env__ enables bootstrapping of Haskell packages + in hermetic build environments without the use of @cabal-install@ or Stack. + + It can be used as a library, or as an executable. + + Please refer to the [readme](https://github.com/bgamari/build-env/readme.md) + for more information and usage examples. + +common common + + build-depends: + base + >= 4.15 && < 4.18 + , bytestring + >= 0.10 && < 0.12 + , containers + ^>= 0.6 + , directory + ^>= 1.3 + , filepath + ^>= 1.4 + , text + >= 1.2 && < 2.1 + + default-language: + Haskell2010 + + default-extensions: + BangPatterns + , BlockArguments + , DerivingStrategies + , DisambiguateRecordFields + , GeneralizedNewtypeDeriving + , LambdaCase + , NamedFieldPuns + , OverloadedStrings + , StandaloneDeriving + , StandaloneKindSignatures + , TypeOperators + , ViewPatterns + + ghc-options: + -Wall + -Wcompat + -fwarn-missing-local-signatures + -fwarn-incomplete-patterns + -fwarn-incomplete-uni-patterns + -fwarn-missing-deriving-strategies + -fno-warn-unticked-promoted-constructors + +library + + import: + common + + hs-source-dirs: + src + + exposed-modules: + BuildEnv.Build + , BuildEnv.BuildOne + , BuildEnv.CabalPlan + , BuildEnv.Config + , BuildEnv.File + , BuildEnv.Script + , BuildEnv.Utils + + build-depends: + async + ^>= 2.2 + , aeson + >= 1.5 && < 2.2 + , process + ^>= 1.6 + , temporary + ^>= 1.3 + , time + >= 1.9 && < 1.13 + , transformers + ^>= 0.5 + +executable build-env + + import: + common + + hs-source-dirs: + app + + main-is: + Main.hs + + ghc-options: + -threaded + + build-depends: + optparse-applicative + >= 0.16 && < 0.18 + , build-env + + other-modules: + BuildEnv.Options + , BuildEnv.Parse
+ changelog.md view
@@ -0,0 +1,9 @@+# Changelog for `build-env` + +## Version 1.0.0.0 (2022-12-26) + +- First released version. + +## Version 0.1.0.0 (2022-11-16) + +- Initial proof of concept.
+ readme.md view
@@ -0,0 +1,263 @@+# build-env <a href="https://hackage.haskell.org/package/build-env" alt="Hackage"><img src="https://img.shields.io/hackage/v/build-env.svg" /></a> + +**`build-env`** is a utility to build a set of Cabal packages (computed +by `cabal-install`'s solver) into a free-standing package database. + +This enables the compilation of Haskell packages (with their dependencies) +in hermetic build environments. + +--- + +### Contents + +- [Example](#example) +- [Commands](#commands) +- [What does `build-env` do?](#what-does-build-env-do) +- [Hermetic builds](#hermetic-builds) + - [Narrowing a build down (for debugging)](#narrowing-a-build-down-for-debugging) +- [Bootstrapping](#bootstrapping) + - [Bootstrap arguments](#bootstrap-arguments) +- [Specifying packages](#specifying-packages) + - [Local packages](#local-packages) + +--- + +## Example + +``` +$ build-env build lens -f sources -o install -j8 +$ ghci -package-db install/package.conf/ -package lens +ghci> :ty Control.Lens.Lens +Control.Lens.Lens + :: Control.Lens.Type.Lens s t a b + -> Control.Lens.Reified.ReifiedLens s t a b +``` + +In this example, the `build-env build` invocation: + + - computes a build plan for `lens`, + - fetches `lens` and its dependencies into the `sources` directory, + - configures and builds all the packages, with up to `8` units building + concurrently, and registers the libraries into the package database at + `install/package.conf`. + +We then tell `ghci` to use this package database, making `lens` available. + +## Commands + +`build-env` has three distinct modes of operation: + + - `plan` computes a build plan, outputting a `plan.json` Cabal plan. + - `fetch` fetches sources. + - `build` executes a build plan. + +Each command subsumes the functionality of the previous ones in the above list. +In particular, in the previous example, the `build` command computed a build +plan, fetched the sources, and built the plan. The same could have been achieved +with three separate invocations: + +``` +$ build-env plan lens -p lens-plan.json +$ build-env fetch -p lens-plan.json -f sources +$ build-env build -p lens-plan.json -f sources -o install -j8 --prefetched +``` + +Being able to separate these steps affords us some extra flexibility, as +subsequent sections will explain. + +## What does `build-env` do? + +- **plan:** `build-env` calls `cabal build --dry-run` on a dummy + project with the dependencies and constraints that were asked for. + This produces a Cabal plan in the form of a `plan.json`, which `build-env` + parses. + **Required executables:** `cabal`, `ghc`. +- **fetch:** `build-env` calls `cabal get` on each package in the build plan. + **Required executables:** `cabal`. +- **build:** `build-env` builds the dependencies by compiling their `Setup` + scripts and calling `Setup configure`, `Setup build`, + `Setup haddock` (optional), `Setup copy`, and, for libraries, `Setup register` + and `ghc-pkg register`. + **Required executables:** `ghc`, `ghc-pkg`. + +Note that, when building packages, `build-env` passes the following information +when running the `Setup` script: + + - `with-compiler`, `prefix` and `destdir` options supplied by the user, + - the default `datadir` option, + - `cid`, `datasubdir`, `bindir` and `builddir` options supplied by `build-env`, + - package flags and `dependency` arguments, obtained from the build plan, + - `<pkg>_datadir` environment variables supplied by `build-env`. + +Problems will likely arise if you pass extra arguments that override any of +these, for example by using `--configure-arg ARG` on the command line. + +## Hermetic builds + +When working in a hermetic build environment, we might need to compute a build +plan and fetch all the dependencies first, before doing an offline build. + +This can be achieved by two separate `build-env` invocations. + +In a local environment (with access to internet): + +``` +$ build-env fetch lens -f sources --output-plan plan.json +``` + +In a build environment in which `build-env` has been provisioned: + +``` +$ build-env build --prefetched -p plan.json -f sources -o install -j8 +``` + +In this example: + + - the `fetch` command computes a plan which is written to + `plan.json` (using the Cabal JSON plan format), and fetches all + the required sources, putting them into the `sources` directory; + - the `build` command reads in the build plan and performs the build. + +### Narrowing a build down (for debugging) + +When attempting to execute a large build plan, it can be useful to be able +to refine the build down to a small set of problematic packages, for debugging +purposes. For this, you can use `--only`, e.g.: + +``` +$ build-env build --prefetched -p plan.json -f sources -o install --only badPackage +``` + +Instead of building the full plan from `plan.json`, this will instead restrict +to only building units from package `badPackage` and their transitive dependencies. +Multiple `--only` arguments combine additively, e.g. `--only pkg1 --only pkg2` +will build the units from the build plan that belong to `pkg1` or `pkg2`, and +transitive dependencies thereof. + +Note that `--only` only affects which packages are built; it does not change +the computation of the build plan nor which packages are fetched. + +You can also resume a build where it left off by passing `--resume`; this will +avoid rebuilding any of the units that have already been registed in the +package database. + +## Bootstrapping + +In the example from [§ Hermetic builds](#hermetic-builds), we ran +`build-env build` to perform a build. However, this requires that the build +environment provision `build-env`. + +To avoid this requirement, it is also possible to ask `build-env` to output +a shell script containing the build steps to execute. + +In a local environment (with access to internet): + +``` +$ build-env build lens -f sources -o install --script build_lens.sh +``` + +In a build environment: + +``` +$ ./build_lens.sh +``` + +The downside is that we lose any parallelism from this approach, as the +generated build script is inherently sequential. + +One particularly useful application is the bootstrapping of `build-env` itself, +as this supplies a mechanism for the provisioning of `build-env` in build +environments. + +### Bootstrap arguments + +If your build environment provides additional variables that need to be passed +to the build script, you can pass them to the shell script using variables. + +For example, in the local environment, you can first obtain all the information +needed to return a build script: + +``` +$ build-env fetch lens -f sources --output-plan myPlan.json +``` + +Next, compute a build script containing references to variables: + +``` +$ build-env build -p myPlan.json -f sources -o install --prefetched --configure-arg \$\{myArgs\} --script script.sh +``` + +This will output a script which passes `$myArgs` to each `Setup configure` +invocation. In the build environment, you can then set the value of `$myArgs` +before running the shell script `script.sh`. Note that the build script +__does not__ insert additional quotation marks around `$myArgs`, which allows +passing multiple arguments at once (this is crucial when one doesn't know +the number of arguments ahead of time). + +If you want to set `--fetchdir`, `--prefix` or `--destdir` to variables, +you should pass the `--variables` flag. This will set these to the values +`$SOURCES`, `$PREFIX` and `$DESTDIR` in the output shell script, respectively. +These should be set to absolute paths before running the script. +The script will also use `$GHC` and `$GHCPKG` variables for the compiler. + +## Specifying packages + +Instead of passing the required packages through the command line, +it can be more convenient to ask `build-env` to read them from files: + +``` +$ build-env build --seeds SEEDS --freeze cabal.config -f sources -o install +``` + +This will build a plan for the packages specified in the `SEEDS` file, +with versions of packages that aren't in the `SEEDS` file constrained as +specified by the `cabal.config` file. + +Each line in the seeds file should be one of the following: + + - A Cabal unit, in the format `unit +flag1 -flag2 >= 0.1 && < 0.3`. + + A unit can be of the form `pkgName`, `lib:pkgName`, `exe:pkgName`, + `pkgName:lib:compName`, ... as per `cabal` component syntax. + + The unit name must be followed by a space. + + Flags and constraints are optional. + When both are present, flags must precede constraints. + Constraints must use valid Cabal constraint syntax. + + - An allow-newer specification such as: + + ``` + allow-newer: pkg1:pkg2,pkg3:base,*:ghc + ``` + + This uses Cabal `allow-newer` syntax. + + - An empty line or comment (starting with `--`). + +The `cabal.config` file should use valid `cabal.project` syntax, e.g.: + +```cabal +constraints: pkg1 == 0.1, + pkg2 >= 3.0 +``` + +Only the constraints are processed; everything else from the `cabal.config` +file is ignored. + +### Local packages + +To specify that a package should be found locally rather than fetched from +Hackage, use `--local`: + +``` +build-env build myPkg --local myPkg=../pkgs/myPkg +``` + +The part to the right of the `=` sign corresponds to what you would write +in a `cabal.project` file: the directory in which `myPkg.cabal` is located. + +If this is a relative path, it will be interpreted relative to the working +directory of the `build-env` invocation, which can be overriden by passing +`--cwd <dir>` (works like the `-C` argument to `make`).
+ src/BuildEnv/Build.hs view
@@ -0,0 +1,724 @@+{-# LANGUAGE DataKinds #-} +{-# LANGUAGE MultiWayIf #-} +{-# LANGUAGE ScopedTypeVariables #-} + +-- | +-- Module : BuildEnv.Build +-- Description : Computing, fetching and building plans +-- +-- 'computePlan' computes a Cabal plan by generating @pkg.cabal@ and +-- @cabal.project@ files with the given dependencies, constraints, flags..., +-- calling @cabal build --dry-run@ to compute a build plan, and parsing +-- the resulting @plan.json@ file. +-- +-- 'fetchPlan' calls @cabal unpack@ to fetch all packages in the given plan. +-- +-- 'buildPlan' builds each unit in the build plan from source, +-- using 'buildUnit'. This can be done either asynchronously or sequentially +-- in dependency order, depending on the 'BuildStrategy'. +-- 'buildPlan' can also be used to output a shell script containing +-- build instructions, with the 'Script' 'BuildStrategy'. +module BuildEnv.Build + ( -- * Computing, fetching and building plans + computePlan + , fetchPlan + , buildPlan + + -- * Generating @pkg.cabal@ and @cabal.project@ files. + , CabalFilesContents(..) + , cabalFileContentsFromPackages + , cabalProjectContentsFromPackages + ) where + +-- base +import Control.Exception + ( IOException, catch ) +import Control.Monad + ( when ) +import Control.Monad.Fix + ( MonadFix(mfix) ) +import Data.Char + ( isSpace ) +import Data.Foldable + ( for_, toList ) +import Data.IORef + ( newIORef ) +import Data.Maybe + ( catMaybes, mapMaybe, maybeToList, isNothing ) +import Data.String + ( IsString ) +import Data.Traversable + ( for ) +import Data.Version + ( Version ) + +-- async +import Control.Concurrent.Async + ( async, wait ) + +-- bytestring +import qualified Data.ByteString.Lazy as Lazy.ByteString + ( readFile ) + +-- containers +import qualified Data.Graph as Graph + ( dfs, graphFromEdges, reverseTopSort ) +import Data.Map.Strict + ( Map ) +import qualified Data.Map.Strict as Map +import qualified Data.Map.Lazy as Lazy + ( Map ) +import qualified Data.Map.Lazy as Lazy.Map +import Data.Set + ( Set ) +import qualified Data.Set as Set + ( elems, fromList, member, toList ) + +-- directory +import System.Directory + ( createDirectoryIfMissing, doesDirectoryExist, doesFileExist + , exeExtension, listDirectory, removeDirectoryRecursive + ) + +-- filepath +import System.FilePath + ( (</>), (<.>) + , isAbsolute + ) + +-- process +import qualified System.Process as Process + ( readProcess ) + +-- text +import Data.Text + ( Text ) +import qualified Data.Text as Text +import qualified Data.Text.IO as Text + ( writeFile ) + +-- build-env +import BuildEnv.BuildOne + ( PkgDbDirs(..) + , getPkgDbDirsForPrep, getPkgDbDirsForBuild, getPkgDir + , setupPackage, buildUnit ) +import BuildEnv.CabalPlan +import qualified BuildEnv.CabalPlan as Configured + ( ConfiguredUnit(..) ) +import BuildEnv.Config +import BuildEnv.Script + ( BuildScript, ScriptOutput(..), ScriptConfig(..) + , emptyBuildScript + , executeBuildScript, script + , createDir, logMessage + ) +import BuildEnv.Utils + ( ProgPath(..), CallProcess(..), callProcessInIO, withTempDir + , AbstractSem(..), noSem, newAbstractSem + ) + +-------------------------------------------------------------------------------- +-- Planning. + +-- | The name of the dummy cabal package on which we will call +-- @cabal@ to obtain a build plan. +dummyPackageName :: IsString str => str +dummyPackageName = "build-env-dummy-package" + +-- | The 'UnitId' of the (local) dummy package (version 0). +dummyUnitId :: UnitId +dummyUnitId = UnitId $ dummyPackageName <> "-0-inplace" + +-- | Query @cabal@ to obtain a build plan for the given packages, +-- by reading the output @plan.json@ of a @cabal build --dry-run@ invocation. +-- +-- Use 'cabalFileContentsFromPackages' and 'cabalProjectContentsFromPackages' +-- to generate the @cabal@ file contents from a collection of packages with +-- constraints and flags. +-- See also 'BuildEnv.File.parseCabalDotConfigPkgs' and +-- 'BuildEnv.File.parseSeedFile' for other ways of obtaining this information. +-- +-- Use 'parsePlanBinary' to convert the returned 'CabalPlanBinary' into +-- a 'CabalPlan'. +computePlan :: TempDirPermanence + -> Verbosity + -> Compiler + -> Cabal + -> CabalFilesContents + -> IO CabalPlanBinary +computePlan delTemp verbosity comp cabal ( CabalFilesContents { cabalContents, projectContents } ) = + withTempDir delTemp "build" \ dir -> do + verboseMsg verbosity $ "Computing plan in build directory " <> Text.pack dir + Text.writeFile (dir </> "cabal" <.> "project") projectContents + Text.writeFile (dir </> dummyPackageName <.> "cabal") cabalContents + let cabalBuildArgs = + globalCabalArgs cabal ++ + [ "build" + , "--dry-run" + , "--with-compiler", ghcPath comp + , cabalVerbosity verbosity ] + debugMsg verbosity $ + Text.unlines $ "cabal" : map ( (" " <>) . Text.pack ) cabalBuildArgs + callProcessInIO Nothing $ + CP { cwd = dir + , prog = AbsPath $ cabalPath cabal + , args = cabalBuildArgs + , extraPATH = [] + , extraEnvVars = [] + , logBasePath = Nothing + , sem = noSem } + + let planPath = dir </> "dist-newstyle" </> "cache" </> "plan.json" + CabalPlanBinary <$> Lazy.ByteString.readFile planPath + +-- | The contents of a dummy @cabal.project@ file, specifying +-- package constraints, flags and allow-newer. +cabalProjectContentsFromPackages :: FilePath -> UnitSpecs -> PkgSpecs -> AllowNewer -> Text +cabalProjectContentsFromPackages workDir units pins (AllowNewer allowNewer) = + packages + <> allowNewers + <> flagSpecs + <> constraints + where + + packages + | null localPkgs + = "packages: .\n\n" + | otherwise + = Text.intercalate ",\n " + ( "packages: ." : map ( Text.pack . makeAbsolute ) ( Map.elems localPkgs ) ) + <> "\n" + + makeAbsolute :: FilePath -> FilePath + makeAbsolute fp + | isAbsolute fp + = fp + | otherwise + = workDir </> fp + + isLocal :: (PkgSrc, PkgSpec, Set ComponentName) -> Maybe FilePath + isLocal ( Local src, _, _ ) = Just src + isLocal _ = Nothing + localPkgs = Map.mapMaybe isLocal units + + allPkgs = fmap ( \ ( _, spec, _ ) -> spec ) units + `unionPkgSpecsOverriding` + pins + -- Constraints from the SEED file (units) should override + -- constraints from the cabal.config file (pins). + + constraints = Text.unlines + [ Text.unwords ["constraints:", unPkgName nm, cts] + | (nm, ps) <- Map.assocs allPkgs + , Constraints cts <- maybeToList $ psConstraints ps + , not (Text.all isSpace cts) + ] + + allowNewers + | null allowNewer + = "" + | otherwise + = Text.unlines $ + "allow-newer:" : + [ " " <> p <> ":" <> q <> "," + | (p,q) <- Set.elems allowNewer ] + + flagSpecs = Text.unlines + [ Text.unlines + [ "package " <> unPkgName nm + , " flags: " <> showFlagSpec (psFlags ps) + ] + | (nm, ps) <- Map.assocs allPkgs + , let flags = psFlags ps + , not $ flagSpecIsEmpty flags + ] + +-- | The contents of a dummy Cabal file with dependencies on +-- the specified units (without any constraints). +cabalFileContentsFromPackages :: UnitSpecs -> Text +cabalFileContentsFromPackages units = + Text.unlines + [ "cabal-version: 3.0" + , "name: " <> dummyPackageName + , "version: 0" + , "library" ] + <> libDepends + <> exeDepends + where + isLib (ComponentName ty lib) = case ty of { Lib -> Just lib; _ -> Nothing } + isExe (ComponentName ty exe) = case ty of { Exe -> Just exe; _ -> Nothing } + allLibs = [ (pkg, libsInPkg) + | (pkg, (_, _, comps)) <- Map.assocs units + , let libsInPkg = mapMaybe isLib $ Set.toList comps + , not (null libsInPkg) ] + allExes = [ (pkg, exesInPkg) + | (pkg, (_, _, comps)) <- Map.assocs units + , let exesInPkg = mapMaybe isExe $ Set.toList comps + , not (null exesInPkg) ] + + dep (PkgName pkg) [comp] + = pkg <> ":" <> comp + dep (PkgName pkg) comps + = pkg <> ":{" <> Text.intercalate "," comps <> "}" + + libDepends + | null allLibs + = "" + | otherwise + = "\n build-depends:\n" + <> Text.intercalate ",\n" + [ " " <> dep pkg libs + | (pkg, libs) <- allLibs ] + <> "\n" + + exeDepends + | null allExes + = "" + | otherwise + = "\n build-tool-depends:\n" + <> Text.intercalate ",\n" + [ " " <> dep pkg exes + | (pkg, exes) <- allExes ] + <> "\n" + +-- | The file contents of the Cabal files of a Cabal project: +-- @pkg.cabal@ and @cabal.project@. +data CabalFilesContents + = CabalFilesContents + { cabalContents :: !Text + -- ^ The package Cabal file contents. + , projectContents :: !Text + -- ^ The @cabal.project@ file contents. + } + +-------------------------------------------------------------------------------- +-- Fetching. + +-- | Fetch the sources of a 'CabalPlan', calling @cabal get@ on each +-- package and putting it into the correspondingly named and versioned +-- subfolder of the specified directory (e.g. @pkg-name-1.2.3@). +fetchPlan :: Verbosity + -> Cabal + -> FilePath -- ^ Directory in which to put the sources. + -> CabalPlan + -> IO () +fetchPlan verbosity cabal fetchDir cabalPlan = + for_ pkgs \ (pkgNm, pkgVer) -> do + let nameVersion = pkgNameVersion pkgNm pkgVer + nmVerStr = Text.unpack nameVersion + pkgDirExists <- doesDirectoryExist (fetchDir </> nmVerStr) + if pkgDirExists + then normalMsg verbosity $ "NOT fetching " <> nameVersion + else cabalFetch verbosity cabal fetchDir nmVerStr + where + pkgs :: Set (PkgName, Version) + pkgs = Set.fromList + -- Some packages might have multiple components; + -- we don't want to fetch the package itself multiple times. + $ mapMaybe remotePkgNameVersion + $ planUnits cabalPlan + + remotePkgNameVersion :: PlanUnit -> Maybe (PkgName, Version) + remotePkgNameVersion = \case + PU_Configured ( ConfiguredUnit { puPkgName = nm, puVersion = ver, puPkgSrc = src } ) + | Remote <- src -- only fetch remote packages + -> Just (nm, ver) + _ -> Nothing + +-- | Call @cabal get@ to fetch a single package from Hackage. +cabalFetch :: Verbosity -> Cabal -> FilePath -> String -> IO () +cabalFetch verbosity cabal root pkgNmVer = do + normalMsg verbosity $ "Fetching " <> Text.pack pkgNmVer + let args = globalCabalArgs cabal ++ + [ "get" + , pkgNmVer + , cabalVerbosity verbosity ] + callProcessInIO Nothing $ + CP { cwd = root + , prog = AbsPath $ cabalPath cabal + , args + , extraPATH = [] + , extraEnvVars = [] + , logBasePath = Nothing + , sem = noSem } + +-------------------------------------------------------------------------------- +-- Building. + +-- | Build a 'CabalPlan'. This will install all the packages in the plan +-- by running their @Setup@ scripts. Libraries will be registered +-- into a local package database at @<install-dir>/package.conf@. +buildPlan :: Verbosity + -> FilePath + -- ^ Working directory. + -- Used to compute relative paths for local packages, + -- and to choose a logging directory. + -> Paths ForPrep + -> Paths ForBuild + -> BuildStrategy + -> Bool + -- ^ @True@ <> resume a previously-started build, + -- skipping over units that were already built. + -- + -- This function will fail if this argument is @False@ + -- and one of the units has already been registered in the + -- package database. + -> Maybe [ UnitId ] + -- ^ @Just units@: only build @units@ and their transitive + -- dependencies, instead of the full build plan. + -> ( ConfiguredUnit -> UnitArgs ) + -- ^ Extra arguments for each unit in the build plan. + -> CabalPlan + -- ^ Build plan to execute. + -> IO () +buildPlan verbosity workDir + pathsForPrep@( Paths { buildPaths = buildPathsForPrep }) + pathsForBuild + buildStrat + resumeBuild + mbOnlyBuildDepsOf + userUnitArgs + cabalPlan + = do + let paths@( BuildPaths { compiler, prefix, destDir, installDir } ) + = buildPaths pathsForBuild + -- Create the temporary package database, if it doesn't already exist. + -- We also create the final installation package database, + -- but this happens later, in (*), as part of the build script itself. + -- + -- See Note [Using two package databases] in BuildOne. + let pkgDbDirsForPrep@( PkgDbDirsForPrep { tempPkgDbDir } ) + = getPkgDbDirsForPrep pathsForPrep + tempPkgDbExists <- doesDirectoryExist tempPkgDbDir + + if + | resumeBuild && not tempPkgDbExists + -> error $ + "Cannot resume build: no package database at " <> tempPkgDbDir + | not resumeBuild + -> do when tempPkgDbExists $ + removeDirectoryRecursive tempPkgDbDir + `catch` \ ( _ :: IOException ) -> return () + createDirectoryIfMissing True tempPkgDbDir + | otherwise + -> return () + + pkgDbDirsForBuild@( PkgDbDirsForBuild { finalPkgDbDir } ) + <- getPkgDbDirsForBuild pathsForBuild + + verboseMsg verbosity $ + Text.unlines [ "Directory structure:" + , " prefix: " <> Text.pack prefix + , " destDir: " <> Text.pack destDir + , " installDir: " <> Text.pack installDir ] + + mbAlreadyBuilt <- + if resumeBuild + then let prepComp = compilerForPrep buildPathsForPrep + in Just <$> getInstalledUnits verbosity prepComp buildPathsForPrep pkgDbDirsForPrep fullDepMap + else return Nothing + + let -- Units to build, in dependency order. + unitsToBuild :: [(ConfiguredUnit, Maybe UnitId)] + unitsToBuild + = tagUnits $ sortPlan mbAlreadyBuilt mbOnlyBuildDepsOf cabalPlan + + nbUnitsToBuild :: Word + nbUnitsToBuild = fromIntegral $ length unitsToBuild + + pkgMap :: Map (PkgName, Version) ConfiguredUnit + pkgMap = Lazy.Map.fromList + [ ((puPkgName, puVersion), cu) + | ( cu@( ConfiguredUnit { puPkgName, puVersion } ), didSetup ) <- unitsToBuild + , isNothing didSetup ] + + -- Initial preparation: logging, and creating the final + -- package database. + preparation :: BuildScript + preparation = do + logMessage verbosity Verbose $ + "Creating final package database at " <> finalPkgDbDir + createDir finalPkgDbDir -- (*) + logMessage verbosity Debug $ "Packages:\n" <> + unlines + [ " - " <> Text.unpack (pkgNameVersion nm ver) + | (nm, ver) <- Map.keys pkgMap ] + logMessage verbosity Debug $ "Units:\n" <> + unlines + [ " - " <> Text.unpack pkgNm <> ":" <> Text.unpack (cabalComponent compName) + | ( ConfiguredUnit + { puPkgName = PkgName pkgNm + , puComponentName = compName } + , _ ) <- unitsToBuild + ] + logMessage verbosity Normal $ "=== BUILD START ===" + + -- Setup the package for this unit. + unitSetupScript :: ConfiguredUnit -> IO BuildScript + unitSetupScript pu = do + let pkgDirForPrep = getPkgDir workDir pathsForPrep pu + pkgDirForBuild = getPkgDir workDir pathsForBuild pu + setupPackage verbosity compiler + paths pkgDbDirsForBuild pkgDirForPrep pkgDirForBuild + fullDepMap pu + + -- Build and install this unit. + unitBuildScript :: ConfiguredUnit -> BuildScript + unitBuildScript pu = + let pkgDirForBuild = getPkgDir workDir pathsForBuild pu + in buildUnit verbosity compiler + paths pkgDbDirsForBuild pkgDirForBuild + (userUnitArgs pu) + fullDepMap pu + + -- Close out the build. + finish :: BuildScript + finish = do + logMessage verbosity Normal $ "=== BUILD SUCCEEDED ===" + + case buildStrat of + + Execute runStrat -> do + + -- Initialise the "units built" counter. + unitsBuiltCounterRef <- newIORef 0 + let unitsBuiltCounter + = Just $ + Counter { counterRef = unitsBuiltCounterRef + , counterMax = nbUnitsToBuild } + + case runStrat of + + Async sem -> do + + normalMsg verbosity $ + "\nBuilding and installing units asynchronously with " <> semDescription sem + executeBuildScript unitsBuiltCounter preparation + + let unitMap :: Lazy.Map UnitId (ConfiguredUnit, Maybe UnitId) + unitMap = + Lazy.Map.fromList + [ (puId, pu) + | pu@( ConfiguredUnit { puId }, _ ) <- unitsToBuild ] + + AbstractSem { withAbstractSem } <- newAbstractSem sem + (_, unitAsyncs) <- mfix \ ~(pkgAsyncs, unitAsyncs) -> do + + let -- Compile the Setup script of the package the unit belongs to. + -- (This should happen only once per package.) + doPkgSetupAsync :: ConfiguredUnit -> IO () + doPkgSetupAsync cu@( ConfiguredUnit { puSetupDepends } ) = do + + -- Wait for the @setup-depends@ units. + for_ puSetupDepends \ setupDepId -> + for_ (unitAsyncs Map.!? setupDepId) wait + + -- Setup the package. + withAbstractSem do + setupScript <- unitSetupScript cu + executeBuildScript unitsBuiltCounter setupScript + + -- Configure, build and install the unit. + doUnitAsync :: ( ConfiguredUnit, Maybe UnitId ) -> IO () + doUnitAsync ( pu, _didSetup ) = do + + let nm = Configured.puPkgName pu + ver = Configured.puVersion pu + + -- Wait for the package to have been setup. + wait $ pkgAsyncs Map.! (nm, ver) + + -- Wait until we have built the units we depend on. + for_ (unitDepends pu) \ depUnitId -> + for_ (unitAsyncs Map.!? depUnitId) wait + + -- Build the unit! + withAbstractSem $ + executeBuildScript unitsBuiltCounter $ unitBuildScript pu + + -- Kick off setting up the packages... + finalPkgAsyncs <- for pkgMap (async . doPkgSetupAsync) + -- ... and building the units. + finalUnitAsyncs <- for unitMap (async . doUnitAsync) + return (finalPkgAsyncs, finalUnitAsyncs) + mapM_ wait unitAsyncs + executeBuildScript unitsBuiltCounter finish + + TopoSort -> do + normalMsg verbosity "\nBuilding and installing units sequentially.\n\ + \NB: pass -j<N> for increased parallelism." + executeBuildScript unitsBuiltCounter preparation + for_ unitsToBuild \ ( cu, didSetup ) -> do + when (isNothing didSetup) $ + unitSetupScript cu >>= executeBuildScript unitsBuiltCounter + executeBuildScript unitsBuiltCounter (unitBuildScript cu) + executeBuildScript unitsBuiltCounter finish + + Script { scriptPath = fp, useVariables } -> do + let scriptConfig :: ScriptConfig + scriptConfig = + ScriptConfig { scriptOutput = Shell { useVariables } + , scriptStyle = hostStyle + , scriptTotal = Just nbUnitsToBuild } + + normalMsg verbosity $ "\nWriting build scripts to " <> Text.pack fp + buildScripts <- for unitsToBuild \ ( cu, didSetup ) -> do + mbSetup <- if isNothing didSetup + then unitSetupScript cu + else return emptyBuildScript + let build = unitBuildScript cu + return $ mbSetup <> build + Text.writeFile fp $ script scriptConfig $ + preparation <> mconcat buildScripts <> finish + + where + + -- This needs to have ALL units, as that's how we pass correct + -- Unit IDs for dependencies. + fullDepMap :: Map UnitId PlanUnit + fullDepMap = Map.fromList + [ (planUnitUnitId pu, pu) + | pu <- planUnits cabalPlan ] + + +-- | Sort the units in a 'CabalPlan' in dependency order. +sortPlan :: Maybe ( Set UnitId ) + -- ^ - @Just skip@ <=> skip these already-built units. + -- - @Nothing@ <=> don't skip any units. + -> Maybe [ UnitId ] + -- ^ - @Just keep@ <=> only return units that belong + -- to the transitive closure of @keep@. + -- - @Nothing@ <=> return all units in the plan. + -> CabalPlan + -> [ConfiguredUnit] +sortPlan mbAlreadyBuilt mbOnlyDepsOf plan = + onlyInteresting $ map (fst3 . lookupVertex) $ Graph.reverseTopSort gr + where + + onlyInteresting :: [ConfiguredUnit] -> [ConfiguredUnit] + onlyInteresting + -- Fast path: don't filter out anything. + | isNothing mbAlreadyBuilt + , isNothing mbOnlyDepsOf + = id + | otherwise + = filter isInteresting + + where + isInteresting :: ConfiguredUnit -> Bool + isInteresting cu@( ConfiguredUnit { puId } ) + | not $ reachable cu + = False + | Just alreadyBuilt <- mbAlreadyBuilt + , puId `Set.member` alreadyBuilt + = False + | otherwise + = True + + reachable :: ConfiguredUnit -> Bool + reachable = + case mbOnlyDepsOf of + Nothing -> const True + Just onlyDepsOf -> + let reachableUnits :: Set UnitId + !reachableUnits + = Set.fromList + $ map ( Configured.puId . fst3 . lookupVertex ) + $ concatMap toList + $ Graph.dfs gr + $ mapMaybe mkVertex onlyDepsOf + in \ ( ConfiguredUnit { puId } ) -> puId `Set.member` reachableUnits + + fst3 :: (a,b,c) -> a + fst3 (a,_,_) = a + ( gr, lookupVertex, mkVertex ) = + Graph.graphFromEdges + [ (pu, puId, allDepends pu) + | PU_Configured pu@( ConfiguredUnit { puId } ) <- planUnits plan ] + +-- | Tag units in a build plan: the first unit we compile in each package +-- is tagged (with @'Nothing'@) as having the responsibility to build +-- the Setup executable for the package it belongs to, while other units +-- in this same package are tagged with @'Just' uid@, where @uid@ is the unit +-- which is responsible for building the Setup executable. +tagUnits :: [ConfiguredUnit] -> [(ConfiguredUnit, Maybe UnitId)] +tagUnits = go Map.empty + where + go _ [] = [] + go seenPkgs ( cu@( ConfiguredUnit { puId } ):cus) + | puId == dummyUnitId + = go seenPkgs cus + | let nm = Configured.puPkgName cu + ver = Configured.puVersion cu + , ( mbUnit, newPkgs ) <- Map.insertLookupWithKey (\_ a _ -> a) (nm,ver) puId seenPkgs + = (cu, mbUnit) : go newPkgs cus + +-- | Compute the set of @UnitId@s that have already been installed, to avoid +-- unnecessarily recompiling them. +-- +-- This set of already-installed units is computed by querying the following: +-- +-- - Library: is it already registered in the package database? +-- - Executable: is there an executable of the correct name in the binary +-- directory associated with the unit? +getInstalledUnits :: Verbosity + -> Compiler + -> BuildPaths ForPrep + -> PkgDbDirs ForPrep + -> Map UnitId PlanUnit + -> IO ( Set UnitId ) +getInstalledUnits verbosity + ( Compiler { ghcPkgPath } ) + ( BuildPathsForPrep { installDir } ) + ( PkgDbDirsForPrep { tempPkgDbDir } ) + plan = do + pkgVerUnitIds <- + words <$> + Process.readProcess ( ghcPkgPath ) + [ "list" + , ghcPkgVerbosity verbosity + , "--show-unit-ids", "--simple-output" + , "--package-db", tempPkgDbDir ] + -- TODO: allow user package databases too? + "" + let installedLibs = map ( UnitId . Text.pack ) pkgVerUnitIds + verboseMsg verbosity $ + "Preinstalled libraries:\n" <> Text.unlines ( map mkLine installedLibs ) + + binDirContents <- listDirectory binsDir + installedBins <- catMaybes <$> mapM binDirMaybe binDirContents + verboseMsg verbosity $ + "Preinstalled executables:\n" <> Text.unlines ( map mkLine installedBins ) + + return $ Set.fromList installedLibs <> Set.fromList installedBins + where + + mkLine :: UnitId -> Text + mkLine ( UnitId uid ) = " - " <> uid + + binsDir :: FilePath + binsDir = installDir </> "bin" + binDirMaybe :: FilePath -> IO (Maybe UnitId) + binDirMaybe binDir = do + isDir <- doesDirectoryExist ( binsDir </> binDir ) + if not isDir + then return Nothing + else + case plan Map.!? ( UnitId $ Text.pack binDir ) of + -- Is this directory name the 'UnitId' of an executable + -- in the build plan? + Just ( PU_Configured cu ) + | ConfiguredUnit + { puId + , puComponentName = + ComponentName + { componentName = comp + , componentType = Exe } + } <- cu + -> do -- If so, does it contain the executable we expect? + let exePath = binsDir </> binDir </> Text.unpack comp <.> exeExtension + exeExists <- doesFileExist exePath + if exeExists + then return $ Just puId + else return Nothing + _ -> return Nothing
+ src/BuildEnv/BuildOne.hs view
@@ -0,0 +1,569 @@+{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE RoleAnnotations #-} +{-# LANGUAGE TypeFamilies #-} + +-- | +-- Module : BuildEnv.BuildOne +-- Description : Configure, build and install a single unit +-- +-- 'setupPackage' prepares a package for building, returning instructions +-- that compile its @Setup@ script. +-- +-- 'buildUnit' computes build instructions to configure, build and install +-- the unit using its @Setup@ script. If the unit is a library, the instructions +-- will also register it into a local package database using @ghc-pkg@. +module BuildEnv.BuildOne + ( -- * Building packages + setupPackage, buildUnit + + -- * Package directory structure helpers + + -- $twoDBs + + , PkgDir(..) + , getPkgDir + , PkgDbDirs(..) + , getPkgDbDirsForPrep, getPkgDbDirsForBuild + ) where + +-- base +import Control.Concurrent + ( QSem, newQSem ) +import Data.Foldable + ( for_ ) +import Data.Kind + ( Type ) +import Data.Maybe + ( maybeToList ) + +-- containers +import Data.Map.Strict + ( Map ) +import qualified Data.Map.Strict as Map + ( lookup ) + +-- directory +import System.Directory + +-- filepath +import System.FilePath + ( (</>), (<.>) + , makeRelative + ) + +-- text +import Data.Text + ( Text ) +import qualified Data.Text as Text + ( unpack ) + +-- build-env +import BuildEnv.Config +import BuildEnv.CabalPlan +import BuildEnv.Script +import BuildEnv.Utils + ( ProgPath(..), CallProcess(..) + , abstractQSem, noSem + ) + +-------------------------------------------------------------------------------- +-- Setup + +-- | Setup a single package. +-- +-- Returns a build script which compiles the @Setup@ script. +setupPackage :: Verbosity + -> Compiler + -> BuildPaths ForBuild -- ^ Overall build directory structure. + -> PkgDbDirs ForBuild -- ^ Package database directories (see 'getPkgDbDirsForBuild'). + -> PkgDir ForPrep -- ^ Package directory (to find the @Setup.hs@). + -> PkgDir ForBuild -- ^ Package directory (to build the @Setup.hs@). + -> Map UnitId PlanUnit -- ^ All dependencies in the build plan. + -> ConfiguredUnit -- ^ The unit to build. + -> IO BuildScript +setupPackage verbosity + ( Compiler { ghcPath } ) + paths@( BuildPaths { installDir, logDir } ) + ( PkgDbDirsForBuild { tempPkgDbDir } ) + ( PkgDir { pkgNameVer, pkgDir = prepPkgDir } ) + ( PkgDir { pkgDir = buildPkgDir } ) + plan + unit@( ConfiguredUnit { puId, puSetupDepends, puExeDepends } ) + + = do let logPath + | verbosity <= Quiet + = Nothing + | otherwise + = Just $ logDir </> Text.unpack ( unUnitId puId ) + -- Find the appropriate Setup.hs file (creating one if necessary) + setupHs <- findSetupHs prepPkgDir + return do + scriptCfg <- askScriptConfig + let setupArgs = [ quoteArg scriptCfg (buildPkgDir </> setupHs) + , "-o" + , quoteArg scriptCfg (buildPkgDir </> "Setup") + , "-package-db=" ++ quoteArg scriptCfg tempPkgDbDir + , ghcVerbosity verbosity + ] ++ map unitIdArg puSetupDepends + + -- Specify location of binaries and data directories. + -- + -- See the commentary around 'depDataDirs' in 'buildUnit'. + binDirs = [ quoteArg scriptCfg $ installDir </> "bin" </> Text.unpack ( unUnitId exeUnitId ) + | exeUnitId <- puExeDepends ] + setupDepDataDirs = + dataDirs scriptCfg paths + [ dep_cu + | depUnitId <- puSetupDepends + , let dep = lookupDependency unit depUnitId plan + , dep_cu <- maybeToList $ configuredUnitMaybe dep + ] + logMessage verbosity Verbose $ + "Compiling Setup.hs for " <> pkgNameVer + callProcess $ + CP { cwd = "." + , prog = AbsPath ghcPath + , args = setupArgs + , extraPATH = binDirs + , extraEnvVars = setupDepDataDirs + , logBasePath = logPath + , sem = noSem + } + +-- | Find the @Setup.hs@/@Setup.lhs@ file to use, +-- or create one using @main = defaultMain@ if none exist. +findSetupHs :: FilePath -> IO FilePath +findSetupHs root = trySetupsOrUseDefault [ "Setup.hs", "Setup.lhs" ] + where + useDefaultSetupHs = do + let path = root </> "Setup.hs" + writeFile path defaultSetupHs + return "Setup.hs" + + try fname = do + let path = root </> fname + exists <- doesFileExist path + return $ if exists then Just fname else Nothing + + defaultSetupHs = unlines + [ "import Distribution.Simple" + , "main = defaultMain" + ] + + trySetupsOrUseDefault [] = useDefaultSetupHs + trySetupsOrUseDefault (setupPath:setups) = do + res <- try setupPath + case res of + Nothing -> trySetupsOrUseDefault setups + Just setup -> return setup + +-------------------------------------------------------------------------------- +-- Build + +-- | Return build steps to to configure, build and and installing the unit, +-- including registering it in the package database if it is a library. +-- +-- You can run the build script with 'executeBuildScript', or you can +-- turn it into a shell script with 'script'. +-- +-- Note: executing the build script will fail if the unit has already been +-- registered in the package database. +buildUnit :: Verbosity + -> Compiler + -> BuildPaths ForBuild -- ^ Overall build directory structure. + -> PkgDbDirs ForBuild -- ^ Package database directories (see 'getPkgDbDirsForBuild'). + -> PkgDir ForBuild -- ^ This package's directory (see 'getPkgDir'). + -> UnitArgs -- ^ Extra arguments for this unit. + -> Map UnitId PlanUnit -- ^ All dependencies in the build plan. + -> ConfiguredUnit -- ^ The unit to build. + -> BuildScript +buildUnit verbosity + ( Compiler { ghcPath, ghcPkgPath } ) + paths@( BuildPaths { installDir, prefix, destDir, logDir } ) + ( PkgDbDirsForBuild + { tempPkgDbDir + , finalPkgDbDir + , tempPkgDbSem + , finalPkgDbSem } ) + ( PkgDir { pkgDir, pkgNameVer } ) + ( UnitArgs { configureArgs = userConfigureArgs + , mbHaddockArgs = mbUserHaddockArgs + , registerArgs = userGhcPkgArgs } ) + plan unit@( ConfiguredUnit { puId, puDepends, puExeDepends } ) + = let compName = Text.unpack $ cabalComponent ( puComponentName unit ) + thisUnit'sId = Text.unpack ( unUnitId puId ) + logPath + | verbosity <= Quiet + = Nothing + | otherwise + = Just $ logDir </> thisUnit'sId + unitPrintableName + | verbosity >= Verbose + = pkgNameVer <> ":" <> compName + | otherwise + = compName + + in do + scriptCfg <- askScriptConfig + + let -- Specify the data directories for all dependencies, + -- including executable dependencies (see (**)). + -- This is important so that e.g. 'happy' can find its datadir. + depDataDirs = + dataDirs scriptCfg paths + [ dep_cu + | depUnitId <- unitDepends unit -- (**) depends ++ exeDepends + , let dep = lookupDependency unit depUnitId plan + , dep_cu <- maybeToList $ configuredUnitMaybe dep + ] + + -- Configure + let flagsArg = case puFlags unit of + flags + | flagSpecIsEmpty flags + -> [] + | otherwise + -> [ "--flags=" ++ quoteArg scriptCfg ( Text.unpack (showFlagSpec flags) ) ] + + -- Set a different build directory for each unit, + -- to avoid clashes when building multiple units from the same + -- package concurrently. + buildDir = quoteArg scriptCfg -- Quote to escape \ on Windows. + $ "temp-build" </> thisUnit'sId + + -- Set a different binDir for each executable unit, + -- so that we can know precisely which executables have been built + -- for the purpose of resumable builds. + thisUnit'sBinDir = quoteArg scriptCfg + $ prefix </> "bin" </> thisUnit'sId + -- NB: use prefix not installDir here, otherwise the copy command + -- will duplicate destDir. + thisUnit'sBinDirArg + | Exe <- cuComponentType unit + = [ "--bindir=" ++ thisUnit'sBinDir ] + | otherwise + = [] + + -- Add the output binary directories to PATH, to satisfy executable + -- dependencies during the build. + binDirs = [ quoteArg scriptCfg $ + installDir </> "bin" </> Text.unpack ( unUnitId exeUnitId ) + | exeUnitId <- puExeDepends ] + + -- NB: make sure to update the readme after changing + -- the arguments that are passed here. + configureArgs = [ "--with-compiler", quoteArg scriptCfg ghcPath + , "--prefix", quoteArg scriptCfg prefix + , "--cid=" ++ Text.unpack (unUnitId puId) + , "--package-db=" ++ quoteArg scriptCfg tempPkgDbDir + , "--exact-configuration" + , "--datasubdir=" ++ pkgNameVer + , "--builddir=" ++ buildDir + , setupVerbosity verbosity + ] ++ thisUnit'sBinDirArg + ++ flagsArg + ++ map ( dependencyArg plan unit ) puDepends + ++ userConfigureArgs + ++ [ buildTarget unit ] + setupExe = RelPath $ runCwdExe scriptCfg "Setup" -- relative to pkgDir + + logMessage verbosity Verbose $ + "Configuring " <> unitPrintableName + logMessage verbosity Debug $ + "Configure arguments:\n" <> unlines (map (" " <>) configureArgs) + callProcess $ + CP { cwd = pkgDir + , prog = setupExe + , args = "configure" : configureArgs + , extraPATH = binDirs + , extraEnvVars = depDataDirs -- Not sure this is needed for 'Setup configure'. + , logBasePath = logPath + , sem = noSem + } + + -- Build + logMessage verbosity Verbose $ + "Building " <> unitPrintableName + callProcess $ + CP { cwd = pkgDir + , prog = setupExe + , args = [ "build" + , "--builddir=" ++ buildDir + , setupVerbosity verbosity ] + , extraPATH = binDirs + , extraEnvVars = depDataDirs + , logBasePath = logPath + , sem = noSem + } + + -- Haddock + for_ mbUserHaddockArgs \ userHaddockArgs -> do + logMessage verbosity Verbose $ + "Building documentation for " <> unitPrintableName + callProcess $ + CP { cwd = pkgDir + , prog = setupExe + , args = [ "haddock" + , "--builddir=" ++ buildDir + , setupVerbosity verbosity ] + ++ userHaddockArgs + , extraPATH = binDirs + , extraEnvVars = depDataDirs + , logBasePath = logPath + , sem = noSem + } + + -- Copy + logMessage verbosity Verbose $ + "Copying " <> unitPrintableName + callProcess $ + CP { cwd = pkgDir + , prog = setupExe + , args = [ "copy", setupVerbosity verbosity + , "--builddir=" ++ buildDir + , "--destdir", quoteArg scriptCfg destDir ] + , extraPATH = [] + , extraEnvVars = [] + , logBasePath = logPath + , sem = noSem + } + + -- Register + case cuComponentType unit of + Lib -> do + -- Register library (in both the local and final package databases) + -- See Note [Using two package databases]. + let pkgRegsFile = thisUnit'sId <> "-pkg-reg.conf" + dirs = [ ( tempPkgDbDir, tempPkgDbSem, "temporary", ["--inplace"], []) + , (finalPkgDbDir, finalPkgDbSem, "final" , [], "--force" : userGhcPkgArgs) ] + + for_ dirs \ (pkgDbDir, pkgDbSem, desc, extraSetupArgs, extraPkgArgs) -> do + + logMessage verbosity Verbose $ + mconcat [ "Registering ", unitPrintableName, " in " + , desc, " package database at:\n " + , pkgDbDir ] + + -- Setup register + callProcess $ + CP { cwd = pkgDir + , prog = setupExe + , args = [ "register", setupVerbosity verbosity + , "--builddir=" ++ buildDir + , "--gen-pkg-config=" ++ pkgRegsFile + ] ++ extraSetupArgs + , extraPATH = [] + , extraEnvVars = [] + , logBasePath = logPath + , sem = noSem + } + + -- NB: we have configured & built a single target, + -- so there should be a single "pkg-reg.conf" file, + -- and not a directory of registration files. + + -- ghc-pkg register + callProcess $ + CP { cwd = pkgDir + , prog = AbsPath ghcPkgPath + , args = [ "register" + , ghcPkgVerbosity verbosity + , "--package-db", quoteArg scriptCfg pkgDbDir + , pkgRegsFile ] + ++ extraPkgArgs + , extraPATH = [] + , extraEnvVars = [] + , logBasePath = logPath + , sem = abstractQSem pkgDbSem + -- Take a lock to avoid contention on the package database + -- when building units concurrently. + } + + _notALib -> return () + + logMessage verbosity Normal $ "Finished building " <> unitPrintableName + reportProgress verbosity + +-- | The argument @-package-id PKG_ID@. +unitIdArg :: UnitId -> String +unitIdArg (UnitId unitId) = "-package-id " ++ Text.unpack unitId + +-- | The target to configure and build. +buildTarget :: ConfiguredUnit -> String +buildTarget ( ConfiguredUnit { puComponentName = comp } ) + = Text.unpack $ cabalComponent comp + +-- | The argument @--dependency=PKG:COMP=UNIT_ID@. +-- +-- Used to specify the 'UnitId' of a dependency to the configure script. +-- This allows us to perform a build using the specific dependencies we have +-- available, ignoring any bounds in the cabal file. +dependencyArg :: Map UnitId PlanUnit -> ConfiguredUnit -> UnitId -> String +dependencyArg fullPlan unitWeAreBuilding depUnitId + = "--dependency=" ++ Text.unpack (mkDependency pu) + where + pu :: PlanUnit + pu = lookupDependency unitWeAreBuilding depUnitId fullPlan + + mkDependency :: PlanUnit -> Text + mkDependency ( PU_Preexisting ( PreexistingUnit { puPkgName = PkgName nm } ) ) + = nm <> "=" <> unUnitId depUnitId + mkDependency ( PU_Configured ( ConfiguredUnit { puPkgName = PkgName pkg + , puComponentName = comp } ) ) + = pkg <> ":" <> componentName comp <> "=" <> unUnitId depUnitId + +-- | Look up a dependency in the full build plan. +-- +-- Throws an error if the dependency can't be found. +lookupDependency :: ConfiguredUnit -- ^ the unit which has the dependency + -- (for error messages only) + -> UnitId -- ^ dependency to look up + -> Map UnitId PlanUnit -- ^ build plan + -> PlanUnit +lookupDependency ( ConfiguredUnit { puPkgName = pkgWeAreBuilding } ) depUnitId plan + | Just pu <- Map.lookup depUnitId plan + = pu + | otherwise + = error $ "buildUnit: can't find dependency in build plan\n\ + \package: " ++ show pkgWeAreBuilding ++ "\n\ + \dependency: " ++ show depUnitId + +-------------------------------------------------------------------------------- +-- Directory structure computation helpers + +{- $twoDBs +__Note [Using two package databases]__ + +We need __two__ distinct package databases: we might want to perform the build +in a temporary location, before everything gets placed into its final +destination. The final package database might use a specific, baked-in +installation prefix (in the sense of @Setup configure --prefix pfx@). As a +result, this package database won't be immediately usable, as we won't have +copied over the build products yet. + +In order to be able to build packages in a temporary directory, we create a +temporary package database that is used for the build, making use of it +with @Setup register --inplace@. +We also register the packages into the final package database using +@ghc-pkg --force@: otherwise, @ghc-pkg@ would error because the relevant files +haven't been copied over yet. +-} + +-- | The package database directories. +-- +-- See Note [Using two package databases]. +type PkgDbDirs :: PathUsability -> Type +data family PkgDbDirs use + +data instance PkgDbDirs ForPrep + = PkgDbDirsForPrep + { tempPkgDbDir :: !FilePath + -- ^ Local package database directory. + } +data instance PkgDbDirs ForBuild + = PkgDbDirsForBuild + { tempPkgDbDir :: !FilePath + -- ^ Local package database directory. + , tempPkgDbSem :: !QSem + -- ^ Semaphore controlling access to the temporary + -- package database. + , finalPkgDbDir :: !FilePath + -- ^ Installation package database directory. + , finalPkgDbSem :: !QSem + -- ^ Semaphore controlling access to the installation + -- package database. + } + +-- | Compute the paths of the package database directories we are going +-- to use. +-- +-- See Note [Using two package databases]. +getPkgDbDirsForPrep :: Paths ForPrep -> PkgDbDirs ForPrep +getPkgDbDirsForPrep ( Paths { fetchDir } ) = + PkgDbDirsForPrep { tempPkgDbDir = fetchDir </> "package.conf" } + +-- | Compute the paths of the package database directories we are going +-- to use, and create some semaphores to control access to them +-- in order to avoid contention. +-- +-- See Note [Using two package databases]. +getPkgDbDirsForBuild :: Paths ForBuild -> IO (PkgDbDirs ForBuild) +getPkgDbDirsForBuild ( Paths { fetchDir, buildPaths = BuildPaths { installDir } } ) = do + tempPkgDbSem <- newQSem 1 + finalPkgDbSem <- newQSem 1 + return $ + PkgDbDirsForBuild { tempPkgDbDir, finalPkgDbDir, tempPkgDbSem, finalPkgDbSem } + where + tempPkgDbDir = fetchDir </> "package.conf" + finalPkgDbDir = installDir </> "package.conf" + +-- | The package @name-version@ string and its directory. +type PkgDir :: PathUsability -> Type +data PkgDir use + = PkgDir + { pkgNameVer :: !String + -- ^ Package @name-version@ string. + , pkgDir :: !FilePath + -- ^ Package directory. + } +type role PkgDir representational + -- Don't allow accidentally passing a @PkgDir ForPrep@ where one expects + -- a @PkgDir ForBuild@. + +-- | Compute the package directory location. +getPkgDir :: FilePath + -- ^ Working directory + -- (used only to relativise paths to local packages). + -> Paths use + -- ^ Overall directory structure to base the computation off. + -> ConfiguredUnit + -- ^ Any unit from the package in question. + -> PkgDir use +getPkgDir workDir ( Paths { fetchDir } ) + ( ConfiguredUnit { puPkgName, puVersion, puPkgSrc } ) + = PkgDir { pkgNameVer, pkgDir } + where + pkgNameVer = Text.unpack $ pkgNameVersion puPkgName puVersion + pkgDir + | Local dir <- puPkgSrc + = makeRelative workDir dir + -- Give local packages paths relative to the working directory, + -- to enable relocatable build scripts. + | otherwise + = fetchDir </> pkgNameVer + +-- | Command to run an executable located the current working directory. +runCwdExe :: ScriptConfig -> FilePath -> FilePath +runCwdExe ( ScriptConfig { scriptOutput, scriptStyle } ) + = pre . ext + where + pre + | Run <- scriptOutput + = id + | otherwise + = ( "./" <> ) + ext + | WinStyle <- scriptStyle + = ( <.> "exe" ) + | otherwise + = id + +-- | An environment containing the data directory paths for the given units. +dataDirs :: ScriptConfig + -> BuildPaths ForBuild + -> [ ConfiguredUnit ] + -> [ ( String, FilePath ) ] +dataDirs scriptCfg ( BuildPaths { installDir } ) units = + [ ( mangledPkgName puPkgName <> "_datadir" + , quoteArg scriptCfg $ + dataDir </> Text.unpack (pkgNameVersion puPkgName puVersion) ) + | ConfiguredUnit { puPkgName, puVersion } <- units ] + where + dataDir = + case scriptStyle scriptCfg of + WinStyle -> installDir + PosixStyle -> installDir </> "share" + -- NB: this is the default value of 'datadir'. + -- If we want the user to be able to specify 'datadir', we should + -- make it into an explicit argument, like 'prefix' & 'destdir'.
+ src/BuildEnv/CabalPlan.hs view
@@ -0,0 +1,458 @@+{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE RecordWildCards #-} + +-- | +-- Module : BuildEnv.CabalPlan +-- Description : Parsing Cabal plans from the @plan.json@ file format +-- +-- This module parses the @plan.json@ Cabal plan files that are created +-- by @cabal-install@. +-- +-- In the future, we hope to avoid doing this, and directly invoke Cabal's +-- solver to obtain the build plan, instead of invoking +-- @cabal-install build --dry-run@ and parsing the resulting @plan.json@ file. +module BuildEnv.CabalPlan + ( -- * Build plans + CabalPlan(..), mapMaybePlanUnits + , CabalPlanBinary(..), parsePlanBinary + + -- * Packages + , PkgName(..) + , pkgNameVersion, validPackageName + , mangledPkgName + + -- ** Allow-newer + + , AllowNewer(..) + + -- ** Package specification + + , PkgSpecs, PkgSpec(..) + , emptyPkgSpec, parsePkgSpec + , unionPkgSpecsOverriding + + -- *** Package constraints + + , Constraints(..) + + -- *** Package flags + , FlagSpec(..) + , showFlagSpec, flagSpecIsEmpty + + -- *** Package source location + , PkgSrc(..) + + -- * Units + + -- ** Units + + , UnitId(..) + , PlanUnit(..) + , planUnitUnitId, planUnitPkgName, planUnitVersion + + , PreexistingUnit(..) + , ConfiguredUnit(..) + , configuredUnitMaybe, cuComponentType + , allDepends, unitDepends + + + -- *** Units within a package + + , UnitSpecs + , unionUnitSpecsCombining + + -- ** Components + , ComponentName(..) + , cabalComponent, parsePkgComponent + , ComponentType(..) + , cabalComponentType, parseComponentType + + ) + where + +-- base +import Data.Char + ( isAlphaNum ) +import Data.Maybe + ( fromMaybe, mapMaybe ) +import Data.Version + ( Version, showVersion ) + +-- aeson +import Data.Aeson + +-- bytestring +import qualified Data.ByteString.Lazy as Lazy + ( ByteString ) + +-- containers +import qualified Data.Map.Strict as Strict + ( Map ) +import qualified Data.Map.Strict as Map +import Data.Set + ( Set ) + +-- text +import Data.Text + ( Text ) +import qualified Data.Text as Text + +------------------------------------------------------------------------------- +-- Build plans + +-- | Units in a Cabal @plan.json@ file. +newtype CabalPlan = CabalPlan { planUnits :: [PlanUnit] } + +mapMaybePlanUnits :: (PlanUnit -> Maybe a) -> CabalPlan -> [a] +mapMaybePlanUnits f (CabalPlan units) = mapMaybe f units + +instance Show CabalPlan where + show (CabalPlan us) + = unlines $ map show us + +instance FromJSON CabalPlan where + parseJSON = withObject "cabal plan" \ o -> + CabalPlan <$> o .: "install-plan" + +-- | Binary data underlying a @cabal@ @plan.json@ file. +newtype CabalPlanBinary = CabalPlanBinary Lazy.ByteString + +-- | Decode a 'CabalPlanBinary' into a 'CabalPlan'. +parsePlanBinary :: CabalPlanBinary -> CabalPlan +parsePlanBinary (CabalPlanBinary pb) = + case eitherDecode pb of + Left err -> error ("parsePlanBinary: failed to parse plan JSON\n" ++ err) + Right plan -> plan + +------------------------------------------------------------------------------- +-- Packages + +-- | A cabal package name, e.g. @lens@, @aeson@. +newtype PkgName = PkgName { unPkgName :: Text } + deriving stock Show + deriving newtype (Eq, Ord, FromJSON, FromJSONKey) + +-- | The @name-version@ string of a package. +pkgNameVersion :: PkgName -> Version -> Text +pkgNameVersion (PkgName n) v = n <> "-" <> Text.pack (showVersion v) + +-- | Is the string a valid @cabal@ package name? That is, does it consist +-- only of alphanumeric identifiers and hyphens? +validPackageName :: Text -> Bool +validPackageName = Text.all ( \ x -> isAlphaNum x || x == '-' ) + +-- | A Cabal mangled package name, in which @-@ has been replaced with @_@. +mangledPkgName :: PkgName -> String +mangledPkgName = map fixupChar . Text.unpack . unPkgName + where + fixupChar '-' = '_' + fixupChar c = c + +-- | A collection of allow-newer specifications, e.g. @pkg1:pkg2,*:base@. +newtype AllowNewer = AllowNewer ( Set (Text, Text) ) + deriving stock Show + deriving newtype ( Semigroup, Monoid ) + +-- | A mapping from a package name to its flags and constraints. +type PkgSpecs = Strict.Map PkgName PkgSpec + +-- | Constraints and flags for a package. +data PkgSpec = PkgSpec { psConstraints :: !( Maybe Constraints ) + , psFlags :: !FlagSpec + } + deriving stock Show + +-- | No flags or constraints on a package. +emptyPkgSpec :: PkgSpec +emptyPkgSpec = PkgSpec Nothing mempty + +-- | Parse flags and constraints (in that order). +parsePkgSpec :: Text -> PkgSpec +parsePkgSpec l = parseSpec Map.empty ( Text.words l ) + where + parseSpec :: Strict.Map Text Bool -> [Text] -> PkgSpec + parseSpec flags [] + = PkgSpec { psConstraints = Nothing + , psFlags = FlagSpec flags } + parseSpec flags (w:ws) + | Just (s,f) <- Text.uncons w + , s == '+' || s == '-' + = parseSpec (Map.insert f (s == '+') flags) ws + | otherwise + = PkgSpec { psConstraints = Just $ Constraints (Text.unwords (w:ws)) + , psFlags = FlagSpec flags } + +instance Semigroup PkgSpec where + ( PkgSpec c1 f1 ) <> ( PkgSpec c2 f2 ) = + PkgSpec ( c1 <> c2 ) + ( f1 <> f2 ) + +-- | Left-biased union of two sets of packages, +-- overriding flags and constraints of the second argument +-- with those provided in the first argument. +unionPkgSpecsOverriding :: PkgSpecs -> PkgSpecs -> PkgSpecs +unionPkgSpecsOverriding = Map.unionWith unionPkgSpec + +-- | Combine two 'UnitSpecs'. Combines constraints and flags. +unionUnitSpecsCombining :: UnitSpecs -> UnitSpecs -> UnitSpecs +unionUnitSpecsCombining = Map.unionWith (<>) + +-- | Left-biased union of package flags and constraints. +unionPkgSpec :: PkgSpec -> PkgSpec -> PkgSpec +unionPkgSpec (PkgSpec strongCts strongFlags) (PkgSpec weakCts weakFlags) + = PkgSpec cts (strongFlags <> weakFlags) + where + cts = case strongCts of + Nothing -> weakCts + _ -> strongCts + +-- | A collection of cabal constraints, e.g. @>= 3.2 && < 3.4@, +-- in raw textual format. +newtype Constraints = Constraints Text + deriving stock Show + +-- | Combine two constraints using @&&@. +instance Semigroup Constraints where + Constraints c1 <> Constraints c2 = + Constraints ( " ( " <> c1 <> " ) && ( " <> c2 <> " )" ) + +-- | Specification of package flags, e.g. @+foo -bar@. +-- +-- @+@ corresponds to @True@ and @-@ to @False@. +newtype FlagSpec = FlagSpec (Strict.Map Text Bool) + deriving stock Show + deriving newtype (Eq, Ord, Semigroup, Monoid, FromJSON) + +showFlagSpec :: FlagSpec -> Text +showFlagSpec (FlagSpec fs) = + Text.unwords + [ sign <> flag + | (flag, value) <- Map.toList fs + , let sign = if value then "+" else "-" + ] + +flagSpecIsEmpty :: FlagSpec -> Bool +flagSpecIsEmpty (FlagSpec fs) = null fs + +-- | The source location of a package. +-- +-- - @Nothing@: it's in the package database (e.g. Hackage). +-- - @Just fp@: specified by the @cabal@ file at the given path. +data PkgSrc + = Remote + | Local !FilePath + deriving stock Show + +instance Semigroup PkgSrc where + Remote <> b = b + a <> _ = a + +instance Monoid PkgSrc where + mempty = Remote + +instance FromJSON PkgSrc where + parseJSON = withObject "package source" \ o -> do + ty <- o .: "type" + case ty :: Text of + "local" -> Local <$> o .: "path" + "repo-tar" -> return Remote + -- <$> o .: "repo" + _ -> + error $ + "parseJSON PkgSrc: unsupported 'pkg-src' field: " + <> Text.unpack ty + +------------------------------------------------------------------------------- +-- Units + +-- | A unique identifier for a unit, +-- e.g. @lens-5.2-1bfd85cb66d2330e59a2f957e87cac993d922401@. +newtype UnitId = UnitId { unUnitId :: Text } + deriving stock Show + deriving newtype (Eq, Ord, FromJSON, FromJSONKey) + +data PlanUnit + = PU_Preexisting !PreexistingUnit + | PU_Configured !ConfiguredUnit + deriving stock Show + +planUnitUnitId :: PlanUnit -> UnitId +planUnitUnitId (PU_Preexisting (PreexistingUnit { puId })) = puId +planUnitUnitId (PU_Configured (ConfiguredUnit { puId })) = puId + +planUnitPkgName :: PlanUnit -> PkgName +planUnitPkgName (PU_Preexisting (PreexistingUnit { puPkgName })) = puPkgName +planUnitPkgName (PU_Configured (ConfiguredUnit { puPkgName })) = puPkgName + +planUnitVersion :: PlanUnit -> Version +planUnitVersion (PU_Preexisting (PreexistingUnit { puVersion })) = puVersion +planUnitVersion (PU_Configured (ConfiguredUnit { puVersion })) = puVersion + +instance FromJSON PlanUnit where + parseJSON = withObject "plan unit" \ o -> do + ty <- o .: "type" + case ty :: Text of + "pre-existing" -> PU_Preexisting <$> preExisting o + "configured" -> PU_Configured <$> configured o + _ -> error $ + "parseJSON PlanUnit: unexpected type " ++ Text.unpack ty ++ ",\n\ + \expecting 'pre-existing' or 'configured'" + where + preExisting o = do + puId <- o .: "id" + puPkgName <- o .: "pkg-name" + puVersion <- o .: "pkg-version" + puDepends <- o .: "depends" + return $ PreexistingUnit {..} + + configured o = do + puId <- o .: "id" + puPkgName <- o .: "pkg-name" + puVersion <- o .: "pkg-version" + puFlags <- fromMaybe (FlagSpec Map.empty) <$> o .:? "flags" + mbComps <- o .:? "components" + puPkgSrc <- o .: "pkg-src" + (puComponentName, puDepends, puExeDepends, puSetupDepends) <- + case mbComps of + Nothing -> do + deps <- o .: "depends" + exeDeps <- o .: "exe-depends" + compName <- o .: "component-name" + let + comp + | compName == "lib" + = ComponentName Lib (unPkgName puPkgName) + | (ty,nm) <- Text.break (== ':') compName + , Just compTy <- parseComponentType ty + , not $ Text.null nm + = ComponentName compTy (Text.drop 1 nm) + | otherwise + = error $ "parseJSON PlanUnit: unsupported component name " + <> Text.unpack compName + return (comp, deps, exeDeps, []) + Just comps -> do + lib <- comps .: "lib" + deps <- lib .: "depends" + exeDeps <- lib .: "exe-depends" + mbSetup <- comps .:? "setup" + setupDeps <- + case mbSetup of + Nothing -> return [] + Just setup -> setup .: "depends" + return (ComponentName Lib (unPkgName puPkgName), deps, exeDeps, setupDeps) + return $ ConfiguredUnit {..} + +-- | Information about a built-in pre-existing unit (such as @base@). +data PreexistingUnit + = PreexistingUnit + { puId :: !UnitId + , puPkgName :: !PkgName + , puVersion :: !Version + , puDepends :: ![UnitId] + } + deriving stock Show + +-- | Information about a unit: name, version, dependencies, flags. +data ConfiguredUnit + = ConfiguredUnit + { puId :: !UnitId + , puPkgName :: !PkgName + , puVersion :: !Version + , puComponentName :: !ComponentName + , puFlags :: !FlagSpec + , puDepends :: ![UnitId] + , puExeDepends :: ![UnitId] + , puSetupDepends :: ![UnitId] + , puPkgSrc :: !PkgSrc + } + deriving stock Show + +configuredUnitMaybe :: PlanUnit -> Maybe ConfiguredUnit +configuredUnitMaybe (PU_Configured pu) = Just pu +configuredUnitMaybe (PU_Preexisting {}) = Nothing + +-- | Get what kind of component this unit is: @lib@, @exe@, etc. +cuComponentType :: ConfiguredUnit -> ComponentType +cuComponentType = componentType . puComponentName + +-- | All the dependencies of a unit: @depends@, @exe-depends@ and @setup-depends@. +allDepends :: ConfiguredUnit -> [UnitId] +allDepends (ConfiguredUnit { puDepends, puExeDepends, puSetupDepends }) = + puDepends ++ puExeDepends ++ puSetupDepends + +-- | The dependencies of a unit, excluding @setup-depends@. +unitDepends :: ConfiguredUnit -> [UnitId] +unitDepends (ConfiguredUnit { puDepends, puExeDepends }) = + puDepends ++ puExeDepends + +-- | A mapping from a package name to its flags, constraints, +-- and components we want to build from it. +type UnitSpecs = Strict.Map PkgName (PkgSrc, PkgSpec, Set ComponentName) + +-- | The name of a cabal component, e.g. @lib:comp@. +data ComponentName = + ComponentName { componentType :: !ComponentType + -- ^ What's before the colon, e.g. @lib@, @exe@, @setup@... + , componentName :: !Text + -- ^ The actual name of the component. + } + deriving stock (Eq, Ord, Show) + +-- | Print a cabal component using colon syntax @ty:comp@. +cabalComponent :: ComponentName -> Text +cabalComponent (ComponentName ty nm) = cabalComponentType ty <> ":" <> nm + +-- | Parse a cabal package component, using the syntax @pkg:ty:comp@, +-- e.g. @attoparsec:lib:attoparsec-internal@. +parsePkgComponent :: Text -> Maybe ( PkgName, ComponentName ) +parsePkgComponent txt = case Text.splitOn ":" txt of + ty:pkg:[] + | Just t <- parseComponentType ty + , validPackageName pkg + -> Just ( PkgName pkg, ComponentName t pkg ) + pkg:ty:comp:[] + | Nothing <- parseComponentType pkg + , validPackageName pkg + , Just t <- parseComponentType ty + , validPackageName comp + -> Just ( PkgName pkg, ComponentName t comp ) + pkg:comp:[] + | Nothing <- parseComponentType pkg + , validPackageName pkg + , validPackageName comp + -> Just ( PkgName comp, ComponentName Lib comp ) + pkg:[] + | Nothing <- parseComponentType pkg + , validPackageName pkg + -> Just ( PkgName pkg, ComponentName Lib pkg ) + _ -> Nothing + +-- | The type of a component, e.g. library, executable, test-suite... +data ComponentType + = Lib + | FLib + | Exe + | Test + | Bench + | Setup + deriving stock (Eq, Ord, Show) + +-- | Print the cabal component type as expected in cabal colon syntax +-- @pkg:ty:comp@. +cabalComponentType :: ComponentType -> Text +cabalComponentType Lib = "lib" +cabalComponentType FLib = "flib" +cabalComponentType Exe = "exe" +cabalComponentType Test = "test" +cabalComponentType Bench = "bench" +cabalComponentType Setup = "setup" + +-- | Parse the type of a @cabal@ component, e.g library, executable, etc. +parseComponentType :: Text -> Maybe ComponentType +parseComponentType "lib" = Just Lib +parseComponentType "flib" = Just FLib +parseComponentType "exe" = Just Exe +parseComponentType "test" = Just Test +parseComponentType "bench" = Just Bench +parseComponentType "setup" = Just Setup +parseComponentType _ = Nothing
+ src/BuildEnv/Config.hs view
@@ -0,0 +1,372 @@+{-# LANGUAGE CPP #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE PatternSynonyms #-} +{-# LANGUAGE TypeFamilies #-} +{-# LANGUAGE UndecidableInstances #-} + +-- | +-- Module : BuildEnv.Config +-- Description : Configuration options for @build-env@ +-- +-- Configuration options for @build-env@ +module BuildEnv.Config + ( -- * Build strategy + BuildStrategy(..), RunStrategy(..) + , AsyncSem(..), semDescription + + -- * Passing arguments + , Args, UnitArgs(..) + + -- * @ghc@ and @cabal-install@ executables + , Compiler(..), Cabal(..) + + -- * Directory structure + , Paths(..), BuildPaths(..) + , PathUsability(..) + , canonicalizePaths + + -- ** Handling of temporary directories + , TempDirPermanence(..) + + -- * Logging verbosity + , Verbosity(.., Quiet, Normal, Verbose, Debug) + , quietMsg, normalMsg, verboseMsg, debugMsg + , ghcVerbosity, ghcPkgVerbosity, cabalVerbosity, setupVerbosity + + -- * Reporting progress + , Counter(..) + + -- * OS specifics + , Style(..), hostStyle + , pATHSeparator + + ) where + +-- base +import Control.Monad + ( when ) +import Data.Kind + ( Type ) +import Data.IORef + ( IORef ) +import Data.Word + ( Word16 ) +import System.IO + ( hFlush, stdout ) + +-- directory +import System.Directory + ( canonicalizePath ) + +-- filepath +import System.FilePath + ( (</>), dropDrive ) + +-- text +import Data.Text + ( Text ) +import qualified Data.Text as Text + ( pack ) +import qualified Data.Text.IO as Text + ( putStrLn ) + +-- time +import Data.Time.Clock + ( getCurrentTime ) +import Data.Time.Format + ( defaultTimeLocale, formatTime ) + +-------------------------------------------------------------------------------- +-- Build strategy + +-- | Build strategy for 'BuildEnv.Build.buildPlan'. +data BuildStrategy + -- | Execute the build plan in-place. + = Execute RunStrategy + -- | Output a build script that can be run later. + | Script + { scriptPath :: !FilePath + -- ^ Output path at which to write the build script. + , useVariables :: !Bool + -- ^ Should the output shell script use variables, or baked in paths? + -- + -- The shell script will use the following variables: + -- + -- - @GHC@, @GHCPKG@, @SOURCES@, @PREFIX@, @DESTDIR@. + } + deriving stock Show + +-- | How to execute a build plan. +data RunStrategy + -- | Topologically sort the cabal build plan, and build the + -- packages in sequence. + = TopoSort + -- | Asynchronously build all the packages, with each package + -- waiting on its dependencies. + | Async + AsyncSem + -- ^ The kind of semaphore to use to control concurrency. + deriving stock Show + +-- | What kind of semaphore to use in 'BuildEnv.Build.buildPlan'? +-- +-- NB: this datatype depends on whether the @jsem@ flag +-- was enabled when building the @build-env@ package. +data AsyncSem + -- | Don't use any semaphore (not recommended). + = NoSem + -- | Create a new 'Control.Concurrent.QSem.QSem' semaphore + -- with the given number of tokens. + | NewQSem !Word16 + deriving stock Show + +-- | A description of the kind of semaphore we are using to control concurrency. +semDescription :: AsyncSem -> Text +semDescription = \case + NoSem -> "no semaphore" + NewQSem i -> "-j" <> Text.pack (show i) + +-------------------------------------------------------------------------------- +-- Arguments + +-- | A type synonym for command-line arguments. +type Args = [String] + +-- | Arguments specific to a unit. +data UnitArgs = + UnitArgs { configureArgs :: !Args + -- ^ Arguments to @Setup configure@. + , mbHaddockArgs :: !(Maybe Args) + -- ^ Arguments to @Setup haddock@. + -- @Nothing@ means: skip @Setup haddock@. + , registerArgs :: !Args + -- ^ Arguments to @ghc-pkg register@. + } + deriving stock Show + +-------------------------------------------------------------------------------- +-- GHC & cabal-install + +-- | Path to the @cabal-install@ executable. +data Cabal = Cabal { cabalPath :: !FilePath + , globalCabalArgs :: !Args + -- ^ Arguments to pass to all @cabal@ invocations, + -- before any @cabal@ command. + } + deriving stock Show + +-- | Paths to the @ghc@ and @ghc-pkg@ executables. +data Compiler = + Compiler { ghcPath :: !FilePath + , ghcPkgPath :: !FilePath + } + deriving stock Show + +-------------------------------------------------------------------------------- +-- Directory structure + +-- | The directory structure relevant to preparing and carrying out +-- a build plan. +type Paths :: PathUsability -> Type +data Paths use + = Paths + { fetchDir :: !FilePath + -- ^ Input fetched sources directory. + , buildPaths :: BuildPaths use + -- ^ Output build directory structure. + -- + -- NB: this will be bottom in the case that we are outputing + -- a shell script that uses variables. + } + +-- | The directory structure relevant to executing a build plan. +type BuildPaths :: PathUsability -> Type +data family BuildPaths use +data instance BuildPaths Raw + = RawBuildPaths + { rawDestDir :: !FilePath + -- ^ Raw output build @destdir@ (might be relative). + , rawPrefix :: !FilePath + -- ^ Raw output build @prefix@ (might be relative). + } +data instance BuildPaths ForPrep + = BuildPathsForPrep + { compilerForPrep :: !Compiler + -- ^ Which @ghc@ and @ghc-pkg@ to use. + , installDir :: !FilePath + -- ^ Output installation directory @destdir/prefix@ (absolute). + } +data instance BuildPaths ForBuild + = BuildPaths + { compiler :: !Compiler + -- ^ Which @ghc@ and @ghc-pkg@ to use. + , destDir :: !FilePath + -- ^ Output build @destdir@ (absolute). + , prefix :: !FilePath + -- ^ Output build @prefix@ (absolute). + , installDir :: !FilePath + -- ^ Output installation directory @destdir/prefix@ (absolute). + , logDir :: !FilePath + -- ^ Directory in which to put logs. + } + +-- | The appropriate stage at which to use a filepath. +data PathUsability + -- | We have just parsed filepaths. They need to be canonicalised + -- before they can be used. + = Raw + -- | The filepaths have been canonicalised. + -- + -- They are now suitable for preparatory build instructions, + -- but not for performing the build. + | ForPrep + -- | The paths are suitable for performing the build. + | ForBuild + +-- | Canonicalise raw 'Paths', computing the appropriate directory structure +-- for preparing and executing a build, respectively. +canonicalizePaths :: Compiler + -> BuildStrategy + -> Paths Raw + -> IO ( Paths ForPrep, Paths ForBuild ) +canonicalizePaths compiler buildStrat + ( Paths + { fetchDir = fetchDir0 + , buildPaths = RawBuildPaths { rawPrefix, rawDestDir } } ) + = do + fetchDir <- canonicalizePath fetchDir0 + prefix <- canonicalizePath rawPrefix + destDir <- canonicalizePath rawDestDir + installDir <- canonicalizePath ( rawDestDir </> dropDrive prefix ) + -- We must use dropDrive here. Quoting from the documentation of (</>): + -- + -- If the second path starts with a path separator or a drive letter, + -- then (</>) returns the second path. + -- + -- We don't want that, as we *do* want to concatenate both paths. + + logDir <- case buildStrat of + Script {} -> return "${LOGDIR}" -- LOGDIR is defined by the script. + Execute {} -> do + -- Pick the logging directory based on the current time. + time <- getCurrentTime + let logDir = "logs" </> formatTime defaultTimeLocale "%0Y-%m-%d_%H-%M-%S" time + canonicalizePath logDir + + let forBuild = case buildStrat of + Script { useVariables } + | useVariables + -> Paths { fetchDir = "${SOURCES}" + , buildPaths = + BuildPaths + { prefix = "${PREFIX}" + , destDir = "${DESTDIR}" + , installDir = "${DESTDIR}" </> "${PREFIX}" + , logDir + , compiler = + Compiler { ghcPath = "${GHC}" + , ghcPkgPath = "${GHCPKG}" } } } + _don'tUseVars -> + Paths { fetchDir + , buildPaths = + BuildPaths { compiler, destDir, prefix, installDir, logDir } } + return $ + ( Paths { fetchDir + , buildPaths = + BuildPathsForPrep { compilerForPrep = compiler, installDir } } + , forBuild ) + +-- | How to handle deletion of temporary directories. +data TempDirPermanence + = DeleteTempDirs + | Don'tDeleteTempDirs + deriving stock Show + +-------------------------------------------------------------------------------- +-- Verbosity + +-- | Verbosity level for the @build-env@ package. +-- +-- The default verbosity level is 'Normal' (1). +newtype Verbosity = Verbosity Int + deriving newtype (Eq, Ord, Num) + deriving stock Show + +-- | Get the flag corresponding to a verbosity, e.g. @-v2@. +verbosityFlag :: Verbosity -> String +verbosityFlag ( Verbosity i ) + | i <= 0 + = "-v0" + | otherwise + = "-v" <> show i + +pattern Quiet, Normal, Verbose, Debug :: Verbosity +pattern Quiet = Verbosity 0 +pattern Normal = Verbosity 1 +pattern Verbose = Verbosity 2 +pattern Debug = Verbosity 3 + +quietMsg, normalMsg, verboseMsg, debugMsg :: Verbosity -> Text -> IO () +quietMsg v msg = when (v >= Quiet ) $ putMsg msg +normalMsg v msg = when (v >= Normal ) $ putMsg msg +verboseMsg v msg = when (v >= Verbose) $ putMsg msg +debugMsg v msg = when (v >= Debug ) $ putMsg msg + +-- | Write the text to @stdout@, and flush. +putMsg :: Text -> IO () +putMsg msg = do + Text.putStrLn msg + hFlush stdout + +ghcVerbosity, ghcPkgVerbosity, cabalVerbosity, setupVerbosity + :: Verbosity -> String +ghcVerbosity = verbosityFlag . min maxGhcVerbosity . subtract 1 +ghcPkgVerbosity = verbosityFlag . min maxGhcPkgVerbosity . subtract 1 +cabalVerbosity = verbosityFlag . min maxCabalVerbosity . subtract 1 +setupVerbosity = verbosityFlag . min maxSetupVerbosity . subtract 1 + +maxGhcVerbosity, maxGhcPkgVerbosity, maxCabalVerbosity, maxSetupVerbosity + :: Verbosity +maxGhcVerbosity = Verbosity 3 +maxGhcPkgVerbosity = Verbosity 2 +maxCabalVerbosity = Verbosity 3 +maxSetupVerbosity = maxCabalVerbosity + +-------------------------------------------------------------------------------- +-- Reporting progress. + +-- | A counter to measure progress, as units are compiled. +data Counter = + Counter + { counterRef :: !( IORef Word ) + -- ^ The running count. + , counterMax :: !Word + -- ^ The maximum that we're counting up to. + } + +-------------------------------------------------------------------------------- +-- Posix/Windows style differences. + +-- | Whether to use Posix or Windows style: +-- +-- - for executables, @./prog@ vs @prog.exe@, +-- - for the path separator, @:@ vs @;@. +data Style + = PosixStyle + | WinStyle + +-- | OS-dependent separator for the PATH environment variable. +pATHSeparator :: Style -> String +pATHSeparator PosixStyle = ":" +pATHSeparator WinStyle = ";" + +-- | The style associated with the OS the program is currently running on. +hostStyle :: Style +hostStyle = +#if defined(mingw32_HOST_OS) + WinStyle +#else + PosixStyle +#endif
+ src/BuildEnv/File.hs view
@@ -0,0 +1,152 @@+{-# LANGUAGE TypeApplications #-} + +-- | +-- Module : BuildEnv.File +-- Description : Parse packages and units from files +-- +-- This module implements the parsing of the two file formats supported +-- by @build-env@: +-- +-- - SEED files, containing a list of seed units from which to compute +-- a build plan. See 'parseSeedFile'. +-- +-- - @cabal.config@ files containing version constraints on packages. +-- See 'parseCabalDotConfigPkgs'. +module BuildEnv.File + ( parseCabalDotConfigPkgs, parseSeedFile ) + where + +-- base +import Data.Char + ( isSpace ) + +-- containers +import qualified Data.Map.Strict as Map +import qualified Data.Set as Set + +-- text +import Data.Text + ( Text ) +import qualified Data.Text as Text +import qualified Data.Text.IO as Text + +-- build-env +import BuildEnv.CabalPlan + +-------------------------------------------------------------------------------- + +-- | Parse constrained packages from the @constraints@ stanza +-- of the @cabal.config@ file at the given filepath: +-- +-- > constraints: pkg1 ==ver1, +-- > pkg2 ==ver2, +-- > ... +-- +-- This function disregards all other contents of the @cabal.config@ package. +parseCabalDotConfigPkgs :: FilePath -> IO PkgSpecs +parseCabalDotConfigPkgs fp = do + ls <- filter ( not . isCommentLine . Text.strip ) + . Text.lines + <$> Text.readFile fp + return $ outsideStanza Map.empty ls + where + outsideStanza :: PkgSpecs -> [Text] -> PkgSpecs + outsideStanza pkgs [] + = pkgs + outsideStanza pkgs (l:ls) + | Just rest <- Text.stripPrefix "constraints:" l + = inConstraintsStanza (pkgs `addPkgFromLine` rest) ls + | otherwise + = outsideStanza pkgs ls + + inConstraintsStanza :: PkgSpecs -> [Text] -> PkgSpecs + inConstraintsStanza pkgs [] + = pkgs + inConstraintsStanza pkgs (l:ls) + | let (ws, rest) = Text.span isSpace l + , not $ Text.null ws + = inConstraintsStanza (pkgs `addPkgFromLine` rest) ls + | otherwise + = outsideStanza pkgs (l:ls) + + addPkgFromLine :: PkgSpecs -> Text -> PkgSpecs + addPkgFromLine pkgs l = + let (pkgName, pkgSpec) = parseCabalDotConfigLine l + in Map.insert pkgName pkgSpec pkgs + +-- | Parse a 'PkgName' and 'PkgSpec' from a line in a @cabal.config@ file. +-- +-- Assumes whitespace has already been stripped. +parseCabalDotConfigLine :: Text -> (PkgName, PkgSpec) +parseCabalDotConfigLine txt + | let (pkg, rest) + = Text.break isSpace + $ Text.dropAround (',' ==) -- drop commas + $ txt + , validPackageName pkg + = ( PkgName pkg, parsePkgSpec rest ) + | otherwise + = error $ "Invalid package in cabal.config file : " <> Text.unpack txt + where + +-- NB: update the readme after changing the documentation below. + +-- | Parse a seed file. Each line must either be: +-- +-- - A Cabal unit, in the format @unit +flag1 -flag2 >= 0.1 && < 0.3@. +-- +-- A unit can be of the form @pkgName@, @lib:pkgName@, @exe:pkgName@, +-- @pkgName:lib:compName@, ... as per Cabal component syntax. +-- +-- The unit name must be followed by a space. +-- +-- Flags and constraints are optional. +-- When both are present, flags must precede constraints. +-- Constraints must use valid Cabal constraint syntax. +-- +-- - An allow-newer specification, e.g. @allow-newer: pkg1:pkg2,*:base,...@. +-- This is not allowed to span multiple lines. +-- +-- Returns @(units, allowNewer)@. +parseSeedFile :: FilePath -> IO (UnitSpecs, AllowNewer) +parseSeedFile fp = do + ls <- filter ( not . isCommentLine ) + . map Text.strip + . Text.lines + <$> Text.readFile fp + return $ go Map.empty mempty ls + + where + go :: UnitSpecs -> AllowNewer -> [Text] -> (UnitSpecs, AllowNewer) + go units ans [] = (units, ans) + go units ans (l:ls) + | Just an <- Text.stripPrefix "allow-newer:" l + = go units (ans <> parseAllowNewer an) ls + | let (pkgTyComp, rest) = Text.break isSpace l + , Just (pkgName, comp) <- parsePkgComponent pkgTyComp + , let spec = parsePkgSpec rest + thisUnit = Map.singleton pkgName + (Remote, spec, Set.singleton comp) + -- we assume units in a seed file + -- don't refer to local packages + = go (units `unionUnitSpecsCombining` thisUnit) ans ls + | otherwise + = error $ "Invalid package in seed file : " <> Text.unpack l + +isCommentLine :: Text -> Bool +isCommentLine l + = Text.null l + || Text.isPrefixOf "--" l + +parseAllowNewer :: Text -> AllowNewer +parseAllowNewer l = + AllowNewer $ Set.fromList $ map parseOneAllowNewer (Text.splitOn "," l) + where + parseOneAllowNewer t + | (Text.strip -> a, Text.strip . Text.drop 1 -> b) <- Text.breakOn ":" t + , a == "*" || validPackageName a + , b == "*" || validPackageName b + = (a,b) + | otherwise + = error $ "Invalid allow-newer syntax in seed file: " <> Text.unpack t +
+ src/BuildEnv/Script.hs view
@@ -0,0 +1,433 @@+{-# LANGUAGE DerivingVia #-} +{-# LANGUAGE FlexibleInstances #-} + +-- | +-- Module : BuildEnv.Script +-- Description : Tiny build script DSL +-- +-- This modules provides a tiny build script DSL. +-- +-- A 'BuildScript' is a series of simple build steps (process calls). +-- +-- A 'BuildScript' can be executed in the 'IO' monad, using 'executeBuildScript'. +-- +-- A 'BuildScript' can be turned into a shell script which can be executed +-- later, using 'script'. +module BuildEnv.Script + ( -- * Interpreting build scripts + + -- ** Executing build scripts + executeBuildScript + + -- ** Shell-script output + , script + + -- * Build scripts + , BuildScript, BuildScriptM(..) + , emptyBuildScript, askScriptConfig + , buildSteps + + -- ** Individual build steps + , BuildStep(..), BuildSteps + , step + , callProcess, createDir, logMessage, reportProgress + + -- ** Configuring build scripts + , ScriptOutput(..), ScriptConfig(..), hostRunCfg + , quoteArg + + ) where + +-- base +import Control.Monad + ( when ) +import Data.Foldable + ( traverse_, foldl', for_ ) +import Data.IORef + ( atomicModifyIORef' ) +import Data.Monoid + ( Ap(..) ) +import Data.String + ( IsString(..) ) +import System.IO + ( hFlush ) +import qualified System.IO as System + ( stdout ) + +-- directory +import System.Directory + ( createDirectoryIfMissing ) + +-- filepath +import System.FilePath + ( (<.>) ) + +-- text +import Data.Text + ( Text ) +import qualified Data.Text as Text + +-- transformers +import Control.Monad.Trans.Reader + ( ReaderT(..) ) +import Control.Monad.Trans.Writer.CPS + ( Writer, execWriter, tell ) + +-- build-env +import BuildEnv.Config + ( Verbosity(..), Counter(..), Style(..) + , hostStyle + ) +import BuildEnv.Utils + ( ProgPath(..), CallProcess(..), callProcessInIO ) + +-------------------------------------------------------------------------------- +-- Build scripts: general monad setup. + +-- | A build script: a list of build steps, given a 'ScriptConfig' context. +type BuildScript = BuildScriptM () + +deriving via Ap BuildScriptM () + instance Semigroup BuildScript +deriving via Ap BuildScriptM () + instance Monoid BuildScript + +-- | Build script monad. +newtype BuildScriptM a = + BuildScript + { runBuildScript :: ReaderT ScriptConfig ( Writer BuildSteps ) a } + deriving newtype ( Functor, Applicative, Monad ) + +-- | The empty build script: no build steps. +emptyBuildScript :: BuildScript +emptyBuildScript = return () + +-- | Retrieve the 'ScriptConfig' from the 'ReaderT' environment. +askScriptConfig :: BuildScriptM ScriptConfig +askScriptConfig = BuildScript $ ReaderT return + +-- | Obtain the build steps of a 'BuildScript'. +buildSteps :: ScriptConfig -> BuildScript -> BuildSteps +buildSteps cfg buildScript + = execWriter (runBuildScript buildScript `runReaderT` cfg) + +-------------------------------------------------------------------------------- +-- Individual build steps + +-- | A list of build steps. +type BuildSteps = [BuildStep] + +-- | A build step. +data BuildStep + -- | Call a processs with the given arguments. + = CallProcess CallProcess + -- | Create the given directory. + | CreateDir FilePath + -- | Log a message to @stdout@. + | LogMessage String + -- | Report one unit of progress. + | ReportProgress + { outputProgress :: Bool + -- ^ Whether to log the progress to @stdout@. + } + +-- | Declare a build step. +step :: BuildStep -> BuildScript +step s = BuildScript $ ReaderT \ _ -> tell [s] + +-- | Call a process with given arguments. +callProcess :: CallProcess -> BuildScript +callProcess = step . CallProcess + +-- | Create the given directory. +createDir :: FilePath -> BuildScript +createDir = step . CreateDir + +-- | Log a message. +logMessage :: Verbosity -> Verbosity -> String -> BuildScript +logMessage v msg_v msg + | v >= msg_v + = step $ LogMessage msg + | otherwise + = return () + +-- | Report one unit of progress. +reportProgress :: Verbosity -> BuildScript +reportProgress v = step ( ReportProgress { outputProgress = v > Quiet } ) + +-------------------------------------------------------------------------------- +-- Configuration + +-- | How to interpret the build script: run it in 'IO', or turn it +-- into a shell script? +data ScriptOutput + -- | Run the build script in 'IO' + = Run + -- | Generate a shell script. + | Shell + { useVariables :: !Bool + -- ^ Replace various values with variables, so that + -- they can be set before running the build script. + -- + -- Values: + -- + -- - @GHC@ and @GHC-PKG@, + -- - fetched sources directory @SOURCES@, + -- - @PREFIX@ and @DESTDIR@. + } + +-- | Configuration options for a 'BuildScript'. +data ScriptConfig + = ScriptConfig + { scriptOutput :: !ScriptOutput + -- ^ Whether we are outputting a shell script, so that we can know whether + -- we should: + -- + -- - add quotes around command-line arguments? + -- - add @./@ to run an executable in the current working directory? + , scriptStyle :: !Style + -- ^ Whether to use Posix or Windows style conventions. See 'Style'. + + , scriptTotal :: !(Maybe Word) + -- ^ Optional: the total number of units we are building; + -- used to report progress. + } + +-- | Configure a script to run on the host (in @IO@). +hostRunCfg :: Maybe Word -- ^ Optional: total to report progress against. + -> ScriptConfig +hostRunCfg mbTotal = + ScriptConfig + { scriptOutput = Run + , scriptStyle = hostStyle + , scriptTotal = mbTotal } + +-- | Quote a string, to avoid spaces causing the string +-- to be interpreted as multiple arguments. +q :: ( IsString r, Monoid r ) => String -> r +q t = "\"" <> fromString (escapeArg t) <> "\"" + where + escapeArg :: String -> String + escapeArg = reverse . foldl' escape [] + + escape :: String -> Char -> String + escape cs c + | '\\' == c + || '\'' == c + || '"' == c + = c:'\\':cs + | otherwise + = c:cs + +-- | Quote a command-line argument, if the 'ScriptConfig' requires arguments +-- to be quoted. +-- +-- No need to call this on the 'cwd' or 'prog' fields of 'CallProcess', +-- as these will be quoted by the shell-script backend no matter what. +quoteArg :: ( IsString r, Monoid r ) => ScriptConfig -> String -> r +quoteArg ( ScriptConfig { scriptOutput } ) = + case scriptOutput of + Run -> fromString + Shell {} -> q + +-------------------------------------------------------------------------------- +-- Interpretation + +------ +-- IO + +-- | Execute a 'BuildScript' in the 'IO' monad. +executeBuildScript :: Maybe Counter -- ^ Optional counter to use to report progress. + -> BuildScript -- ^ The build script to execute. + -> IO () +executeBuildScript counter + = traverse_ ( executeBuildStep counter ) + . buildSteps ( hostRunCfg $ fmap counterMax counter ) + +-- | Execute a single 'BuildStep' in the 'IO' monad. +executeBuildStep :: Maybe Counter + -- ^ Optional counter to use to report progress. + -> BuildStep + -> IO () +executeBuildStep mbCounter = \case + CallProcess cp -> callProcessInIO mbCounter cp + CreateDir dir -> createDirectoryIfMissing True dir + LogMessage msg -> do { putStrLn msg ; hFlush System.stdout } + ReportProgress { outputProgress } -> + for_ mbCounter \ counter -> do + completed <- + atomicModifyIORef' ( counterRef counter ) + ( \ x -> let !x' = x+1 in (x',x') ) + when outputProgress do + let + txt = "## " <> show completed <> " of " <> show ( counterMax counter ) <> " ##" + n = length txt + putStrLn $ unlines + [ "" + , " " <> replicate n '#' + , " " <> txt + , " " <> replicate n '#' + , "" ] + +---------------- +-- Shell script + +-- | Obtain the textual contents of a build script. +script :: ScriptConfig -> BuildScript -> Text +script scriptCfg buildScript = + Text.unlines ( header ++ concatMap ( stepScript scriptCfg ) ( buildSteps scriptCfg buildScript ) ) + where + header, varsHelper, progressVars :: [ Text ] + header = [ "#!/bin/bash" , "" ] ++ varsHelper ++ logDir ++ progressVars + varsHelper + | Shell { useVariables } <- scriptOutput scriptCfg + , useVariables + = variablesHelper + | otherwise + = [] + progressVars = + case scriptTotal scriptCfg of + Nothing -> [] + Just {} -> + [ "buildEnvProgress=0" ] + logDir = [ "LOGDIR=\"$PWD/logs/$(date --utc +%Y-%m-%d_%H-%M-%S)\"" + , "mkdir -p \"${LOGDIR}\"" ] + +-- | The underlying script of a build step. +stepScript :: ScriptConfig -> BuildStep -> [ Text ] +stepScript scriptCfg = \case + CreateDir dir -> + [ "mkdir -p " <> q dir ] + LogMessage str -> + [ "echo " <> q str ] + ReportProgress { outputProgress } -> + case scriptTotal scriptCfg of + Nothing + -> [] + Just tot + | outputProgress + -> [ Text.pack $ "printf \"" <> txt <> "\" $((++buildEnvProgress))" ] + | otherwise + -> [ "((++buildEnvProgress))" ] + -- Still increment the progress variable, as we use this variable + -- to report progress upon failure. + where + n = length $ show tot + l = 2 * n + 10 + txt = "\\n " <> replicate l '#' <> "\\n " + <> "## %0" <> show n <> "d of " <> show tot <> " ##" <> "\\n " + <> replicate l '#' <> "\\n" + + CallProcess ( CP { cwd, extraPATH, extraEnvVars, prog, args, logBasePath } ) -> + -- NB: we ignore the semaphore, as the build scripts we produce + -- are inherently sequential. + logCommand ++ + [ "( cd " <> q cwd <> " ; \\" ] + ++ mbUpdatePath + ++ map mkEnvVar extraEnvVars + ++ + [ " " <> cmd <> pipeToLogs <> " )" + , resVar <> "=$?" + , "if [ \"${" <> resVar <> "}\" -eq 0 ]" + , "then true" + , "else" + , " echo -e " <> + "\"callProcess failed with non-zero exit code. Command:\\n" <> + " > " <> unquote cmd <> "\\n" <> + " CWD = " <> unquote (q cwd) <> "\"" + <> logErr + ] + ++ progressReport + ++ logMsg + ++ + [ " exit \"${" <> resVar <> "}\"" + , "fi" ] + where + cmd :: Text + cmd = q ( progPath prog ) <> " " <> Text.unwords (map Text.pack args) + -- (1) (2) + -- + -- (1) + -- In shell scripts, we always change directory before running the + -- program. As the program path is either absolute or relative to @cwd@, + -- we don't need to modify the program path. + -- + -- (2) + -- Don't quote the arguments: arguments which needed quoting will have + -- been quoted using the 'quoteArg' function. + -- + -- This allows users to pass multiple arguments using variables: + -- + -- > myArgs="arg1 arg2 arg3" + -- > Setup configure $myArgs + -- + -- by passing @$myArgs@ as a @Setup configure@ argument. + resVar :: Text + resVar = "buildEnvLastExitCode" + mbUpdatePath :: [Text] + mbUpdatePath + | null extraPATH + = [] + | otherwise + = [ " export PATH=$PATH:" + <> Text.intercalate ":" (map Text.pack extraPATH) -- (already quoted) + <> " ; \\" ] + + mkEnvVar :: (String, String) -> Text + mkEnvVar (var,val) = " export " + <> Text.pack var + <> "=" <> Text.pack val <> " ; \\" + -- (already quoted) + + logCommand, logMsg :: [Text] + pipeToLogs, logErr :: Text + (logCommand, pipeToLogs, logErr, logMsg) = + case logBasePath of + Nothing -> ( [], "", " >&2", [] ) + Just logPath -> + let stdoutFile, stderrFile :: Text + stdoutFile = q ( logPath <.> "stdout" ) + stderrFile = q ( logPath <.> "stderr" ) + in ( [ "echo \"> " <> unquote cmd <> "\" >> " <> stdoutFile ] + , " > " <> stdoutFile <> " 2> >( tee -a " <> stderrFile <> " >&2 )" + -- Write stdout to the stdout log file. + -- Write stderr both to the terminal and to the stderr log file. + , " | tee -a " <> stderrFile <> " >&2" + , [ " echo \"Logs are available at: " <> unquote ( q ( logPath <> ".{stdout,stderr}" ) ) <> "\"" ] ) + + progressReport :: [Text] + progressReport = + case scriptTotal scriptCfg of + Nothing -> [] + Just tot -> + [ " echo \"After ${buildEnvProgress} of " <> Text.pack (show tot) <> "\"" ] + +unquote :: Text -> Text +unquote = Text.filter ( not . (== '\"') ) + +---- +-- Helper to check that environment variables are set as expected. + +-- | All the environment variables that a shell script using variables +-- expects to be set. +allVars :: [ Text ] +allVars = [ "GHC", "GHCPKG", "SOURCES", "PREFIX", "DESTDIR" ] + +-- | A preamble that checks the required environment variables are defined. +variablesHelper :: [ Text ] +variablesHelper = + [ "", "echo \"Checking that required environment variables are set.\"" ] + ++ concatMap variableHelper allVars + ++ [ "" ] + +-- | Check that the given environment variable is defined, giving an error +-- message if it isn't (to avoid an error cascade). +variableHelper :: Text -> [ Text ] +variableHelper varName = + [ "if [ -z ${" <> varName <> "} ]" + , "then" + , " echo \"Environment variable " <> varName <> " not set.\"" + , " echo \"When using --variables, the build script expects the following environment variables to be set:\"" + , " echo \" " <> Text.intercalate ", " allVars <> ".\"" + , " exit 1" + , "fi" ]
+ src/BuildEnv/Utils.hs view
@@ -0,0 +1,253 @@+{-# LANGUAGE DataKinds #-} +{-# LANGUAGE PolyKinds #-} +{-# LANGUAGE RankNTypes #-} +{-# LANGUAGE TupleSections #-} +{-# LANGUAGE TypeFamilies #-} + +-- | +-- Module : BuildEnv.Utils +-- Description : Utilities for @build-env@ +-- +-- Various utilities: +-- +-- - Spawning of processes in particular environments; see 'callProcessInIO'. +-- - Semaphores. +-- +module BuildEnv.Utils + ( -- * Call a process in a given environment + ProgPath(..), CallProcess(..), callProcessInIO + + -- * Create temporary directories + , TempDirPermanence(..), withTempDir + + -- * Abstract semaphores + , AbstractSem(..) + , newAbstractSem, noSem, abstractQSem + + -- * Other utilities + , splitOn + + ) where + +-- base +import Control.Concurrent.QSem + ( QSem, newQSem, signalQSem, waitQSem ) +import Control.Exception + ( bracket_ ) +import Data.List + ( intercalate ) +import Data.Maybe + ( fromMaybe ) +import Data.IORef + ( readIORef ) +import System.Environment + ( getEnvironment ) +import System.Exit + ( ExitCode(..), exitWith ) +import System.IO + ( IOMode(..), hPutStrLn, withFile ) +import qualified System.IO as System.Handle + ( stderr ) +import GHC.IO.Handle + ( hDuplicateTo ) +import GHC.Stack + ( HasCallStack ) + +-- containers +import Data.Map.Strict + ( Map ) +import qualified Data.Map.Strict as Map + ( alter, fromList, toList ) + +-- directory +import System.Directory + ( createDirectoryIfMissing, makeAbsolute ) + +-- filepath +import System.FilePath + ( (</>), (<.>), takeDirectory ) + +-- process +import qualified System.Process as Proc + +-- temporary +import System.IO.Temp + ( createTempDirectory + , getCanonicalTemporaryDirectory + , withSystemTempDirectory + ) + +-- build-env +import BuildEnv.Config + ( AsyncSem(..), Args, TempDirPermanence(..), Counter(..) + , pATHSeparator, hostStyle + ) + +-------------------------------------------------------------------------------- + +-- | The path of a program to run. +data ProgPath + -- | An absolute path, or an executable in @PATH@. + = AbsPath { progPath :: !FilePath } + -- | A relative path. What it is relative to depends on context. + | RelPath { progPath :: !FilePath } + +-- | Arguments to 'callProcess'. +data CallProcess + = CP + { cwd :: !FilePath + -- ^ Working directory. + , extraPATH :: ![FilePath] + -- ^ Absolute filepaths to add to PATH. + , extraEnvVars :: ![(String, String)] + -- ^ Extra envi!ronment variables to add before running the command. + , prog :: !ProgPath + -- ^ The program to run. + -- + -- If it's a relative path, it should be relative to the @cwd@ field. + , args :: !Args + -- ^ Arguments to the program. + , logBasePath :: !( Maybe FilePath ) + -- ^ Log @stdout@ to @basePath.stdout@ and @stderr@ to @basePath.stderr@. + , sem :: !AbstractSem + -- ^ Lock to take when calling the process + -- and waiting for it to return, to avoid + -- contention in concurrent situations. + } + +-- | Run a command and wait for it to complete. +-- +-- Crashes if the process returns with non-zero exit code. +-- +-- See 'CallProcess' for a description of the options. +callProcessInIO :: HasCallStack + => Maybe Counter + -- ^ Optional counter. Used when the command fails, + -- to report the progress that has been made so far. + -> CallProcess + -> IO () +callProcessInIO mbCounter ( CP { cwd, extraPATH, extraEnvVars, prog, args, logBasePath, sem } ) = do + absProg <- + case prog of + AbsPath p -> return p + RelPath p -> makeAbsolute $ cwd </> p + -- Needs to be an absolute path, as per the @process@ documentation: + -- + -- If cwd is provided, it is implementation-dependent whether + -- relative paths are resolved with respect to cwd or the current + -- working directory, so absolute paths should be used + -- to ensure portability. + -- + -- We always want the program to be interpreted relative to the cwd + -- argument, so we prepend @cwd@ and then make it absolute. + let argsStr + | null args = "" + | otherwise = " " ++ unwords args + command = + [ " > " ++ absProg ++ argsStr + , " CWD = " ++ cwd ] + env <- + if null extraPATH && null extraEnvVars + then return Nothing + else do env0 <- getEnvironment + let env1 = Map.fromList $ env0 ++ extraEnvVars + env2 = Map.toList $ augmentSearchPath "PATH" extraPATH env1 + return $ Just env2 + let withHandles :: ( ( Proc.StdStream, Proc.StdStream ) -> IO () ) -> IO () + withHandles action = case logBasePath of + Nothing -> action ( Proc.Inherit, Proc.Inherit ) + Just logPath -> do + let stdoutFile = logPath <.> "stdout" + stderrFile = logPath <.> "stderr" + createDirectoryIfMissing True $ takeDirectory logPath + withFile stdoutFile AppendMode \ stdoutFileHandle -> + withFile stderrFile AppendMode \ stderrFileHandle -> do + hDuplicateTo System.Handle.stderr stderrFileHandle + -- Write stderr to the log file and to the terminal. + hPutStrLn stdoutFileHandle ( unlines command ) + action ( Proc.UseHandle stdoutFileHandle, Proc.UseHandle stderrFileHandle ) + withHandles \ ( stdoutStream, stderrStream ) -> do + let processArgs = + ( Proc.proc absProg args ) + { Proc.cwd = if cwd == "." then Nothing else Just cwd + , Proc.env = env + , Proc.std_out = stdoutStream + , Proc.std_err = stderrStream } + res <- withAbstractSem sem do + (_, _, _, ph) <- Proc.createProcess_ "createProcess" processArgs + -- Use 'createProcess_' to avoid closing handles prematurely. + Proc.waitForProcess ph + case res of + ExitSuccess -> return () + ExitFailure i -> do + progressReport <- + case mbCounter of + Nothing -> return [] + Just ( Counter { counterRef, counterMax } ) -> do + progress <- readIORef counterRef + return $ [ "After " <> show progress <> " of " <> show counterMax ] + let msg = [ "callProcess failed with non-zero exit code " ++ show i ++ ". Command:" ] + ++ command ++ progressReport + case stderrStream of + Proc.UseHandle errHandle -> + hPutStrLn errHandle + ( unlines $ msg ++ [ "Logs are available at: " <> fromMaybe "" logBasePath <> ".{stdout, stderr}" ] ) + _ -> putStrLn (unlines msg) + exitWith res + +-- | Add filepaths to the given key in a key/value environment. +augmentSearchPath :: Ord k => k -> [FilePath] -> Map k String -> Map k String +augmentSearchPath _ [] = id +augmentSearchPath var paths = Map.alter f var + where + pathsVal = intercalate (pATHSeparator hostStyle) paths + f Nothing = Just pathsVal + f (Just p) = Just (p <> (pATHSeparator hostStyle) <> pathsVal) + +-- | Perform an action with a fresh temporary directory. +withTempDir :: TempDirPermanence -- ^ whether to delete the temporary directory + -- after the action completes + -> String -- ^ directory name template + -> (FilePath -> IO a) -- ^ action to perform + -> IO a +withTempDir del name k = + case del of + DeleteTempDirs + -> withSystemTempDirectory name k + Don'tDeleteTempDirs + -> do root <- getCanonicalTemporaryDirectory + createTempDirectory root name >>= k + +-- | Utility list 'splitOn' function. +splitOn :: Char -> String -> [String] +splitOn c = go + where + go "" = [] + go s + | (a,as) <- break (== c) s + = a : go (drop 1 as) + +-------------------------------------------------------------------------------- +-- Semaphores. + +-- | Abstract acquire/release mechanism. +newtype AbstractSem = + AbstractSem { withAbstractSem :: forall r. IO r -> IO r } + +-- | Create a semaphore-based acquire/release mechanism. +newAbstractSem :: AsyncSem -> IO AbstractSem +newAbstractSem whatSem = + case whatSem of + NoSem -> return noSem + NewQSem n -> do + qsem <- newQSem ( fromIntegral n ) + return $ abstractQSem qsem + +-- | Abstract acquire/release mechanism controlled by the given 'QSem'. +abstractQSem :: QSem -> AbstractSem +abstractQSem sem = + AbstractSem $ bracket_ (waitQSem sem) (signalQSem sem) + +-- | No acquire/release mechanism required. +noSem :: AbstractSem +noSem = AbstractSem { withAbstractSem = id }