packages feed

build-env-1.2.0.0: app/BuildEnv/Parse.hs

{-# 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 Control.Concurrent
  ( getNumCapabilities )
import Data.Char
  ( isSpace )
import Data.Bool
  ( bool )
import Data.Word
  ( Word16 )
import System.Environment
  ( getArgs )
import Text.Read
  ( readMaybe )

-- 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
import Options.Applicative.Help.Pretty
  ( (.$.) )

-- 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.Path
import BuildEnv.Utils
  ( splitOn )

--------------------------------------------------------------------------------

-- | Run the command-line options parser.
runOptionsParser :: IO ( GlobalOpts, Mode )
runOptionsParser = do
  args <- getArgs
  caps <- NumCapabilities . fromIntegral <$> getNumCapabilities
  let
    pInfo =
      info ( helper <*> options caps )
        (  fullDesc
        <> header "build-env - compute, fetch and build Cabal build plans" )
  handleParseResult $ execParserPure pPrefs pInfo args
  where
    pPrefs = prefs $
      mconcat [ showHelpOnEmpty
              , subparserInline
              , helpShowGlobals
              , multiSuffix "*"
              , columns 90 ]

newtype NumCapabilities = NumCapabilities { numCapabilities :: Word16 }
  deriving stock ( Show, Eq, Ord )

-- | The command-line options parser for the 'build-env' executable.
options :: NumCapabilities -> Parser ( GlobalOpts, Mode )
options caps = do
  mode       <- optMode caps
  globalOpts <- optGlobalOpts
  pure ( globalOpts, mode )

-- | Parse global options for 'build-env'.
optGlobalOpts :: Parser GlobalOpts
optGlobalOpts = do
  compiler   <- optCompiler
  cabal      <- optCabal
  workDir    <- optChangeWorkingDirectory
  verbosity  <- optVerbosity
  indexState <- optIndexState
  delTemp    <- optTempDirPermanence
  pure $
    GlobalOpts
      { compiler, cabal
      , verbosity, delTemp
      , workDir, indexState
      }

-- | Parse @ghc@ and @ghc-pkg@ paths.
optCompiler :: Parser Compiler
optCompiler =
    Compiler
      <$> option ( fmap mkAbsolutePath str )
            (  long "ghc"
            <> value ( mkAbsolutePath "ghc" )
            <> help "'ghc' executable path"
            <> metavar "GHC"
            )
      <*> option ( fmap mkAbsolutePath str )
            (  long "ghc-pkg"
            <> value ( mkAbsolutePath "ghc-pkg" )
            <> help "'ghc-pkg' executable path"
            <> metavar "GHC-PKG"
            )

-- | Parse @cabal@ path.
optCabal :: Parser Cabal
optCabal = do
  cabalPath <-
    option ( fmap mkAbsolutePath str )
      (  long "cabal"
      <> value ( mkAbsolutePath "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 :: Parser ( SymbolicPath CWD ( Dir Project ) )
optChangeWorkingDirectory =
    option ( fmap mkSymbolicPath str )
      (  long "cwd"
      <> value sameDirectory
      <> 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 a Hackage index state.
optIndexState :: Parser ( Maybe IndexState )
optIndexState =
  optional $ fmap ( IndexState . Text.pack ) $
    option str ( long "index-state" <> help helpStr <> metavar "DATE" )
  where
    helpStr = "Use Hackage state as of DATE, e.g. 2022-12-25T00:00:00Z"

-- | Parse the mode in which to run the application: plan, fetch, build.
optMode :: NumCapabilities -> Parser Mode
optMode caps =
  hsubparser . mconcat $
    [ command "plan"  $
        info ( PlanMode  <$> planInputs Planning <*> optOutput )
        ( fullDesc <> planInfo )
    , command "fetch" $
        info ( FetchMode <$> fetchDescription <*> newOrExisting )
        ( fullDesc <> fetchInfo )
    , command "build" $
        info ( BuildMode <$> build caps )
        ( fullDesc <> buildInfo )
    ]
  where
    optOutput :: Parser ( SymbolicPath Project File )
    optOutput =
      option ( fmap mkSymbolicPath 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 ( SymbolicPath Project File )
    freezeFile = option ( fmap mkSymbolicPath 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 ( SymbolicPath Project File )
    seeds = option ( fmap mkSymbolicPath 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, SymbolicPath Project ( Dir Pkg ) )
    localPkg =
      option readLocalPkg
        (  long "local"
        <> help "Local package source location"
        <> metavar "PKG=PATH" )

    readLocalPkg :: ReadM ( PkgName, SymbolicPath Project ( Dir Pkg ) )
    readLocalPkg = do
      ln <- str
      case Text.splitOn "=" ln of
        pkg:loc:_
          | validPackageName pkg
          -> return ( PkgName pkg, mkSymbolicPath $ 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 ( SymbolicPath Project File )
    optPlanPath =
      option ( fmap mkSymbolicPath str )
        (  short 'p'
        <> long "plan"
        <> help "Input 'plan.json' file"
        <> metavar "INFILE" )

    optPlanOutput :: Parser ( Maybe ( SymbolicPath Project File ) )
    optPlanOutput =
      option ( fmap ( Just . mkSymbolicPath ) 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
  fetchDir       <- optFetchDir Fetching
  pure $ FetchDescription { fetchDir, fetchInputPlan }

-- | Parse the fetch directory.
optFetchDir :: ModeDescription -> Parser ( SymbolicPath Project ( Dir Fetch ) )
optFetchDir modeDesc =
  option ( fmap mkSymbolicPath 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 :: NumCapabilities -> Parser Build
build ( NumCapabilities numCaps ) = do

  buildBuildPlan <- plan Building
  buildStart     <- optStart
  buildStrategy  <- optStrategy
  buildRawPaths  <- optRawPaths
  userUnitArgs   <- optUnitArgs
  mbOnlyDepsOf   <- optOnlyDepsOf
  eventLogDir    <- optEventLogDir
  pure $
    Build { buildStart
          , buildBuildPlan
          , buildStrategy
          , buildRawPaths
          , userUnitArgs
          , mbOnlyDepsOf
          , eventLogDir }

  where

    optStrategy :: Parser BuildStrategy
    optStrategy = optScript <|> ( Execute <$> optRunStrat )

    optRunStrat :: Parser RunStrategy
    optRunStrat = ( Async <$> asyncSem ) <|> pure TopoSort

    asyncSem :: Parser AsyncSem
    asyncSem =
      option jsem
        (  long "jsem"
        <> help "Use a system semaphore to control parallelism"
        <> metavar "[N|SEM_NAME]" )
      <|>
      option ( fmap NewQSem j )
        (  short 'j'
        <> help "Use asynchronous package building"
        <> metavar "[N]" )

    j :: ReadM Word16
    j = do
      x <- str
      if null x
      then return numCaps
      else
        case readMaybe x of
          Just n -> return n
          Nothing ->
           readerError $
             "Invalid -j argument: " <> x

    jsem :: ReadM AsyncSem
    jsem = do
      x <- str
      return $
        if null x
        then NewJSem numCaps
        else
          case readMaybe x of
            Just n  -> NewJSem n
            Nothing -> ExistingJSem x

    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 ( SymbolicPath CWD File )
    optScriptPath =
      option ( fmap mkSymbolicPath str )
        (  long "script"
        <> helpDoc ( Just $  "Output a shell script containing build steps"
                         .$. "  NB: path is interpreted relative to current work dir, NOT --cwd" )
        <> 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 ( fmap mkSymbolicPath str )
          (  short 'o'
          <> long "prefix"
          <> help "Installation prefix"
          <> metavar "OUTDIR" )
      rawDestDir <-
        option ( fmap mkSymbolicPath str )
          (  long "destdir"
          <> help "Installation destination directory"
          <> value ( mkSymbolicPath "/" )
          <> 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

    optEventLogDir :: Parser ( Maybe ( SymbolicPath Project ( Dir Logs ) ) )
    optEventLogDir = do
        option ( fmap ( Just . mkSymbolicPath ) str )
          (  long "eventlogs"
          <> help "Directory for GHC event logs"
          <> value Nothing
          <> metavar "OUTDIR" )