cabal-install-3.10.2.0: src/Distribution/Client/ScriptUtils.hs
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
-- | Utilities to help commands with scripts
--
module Distribution.Client.ScriptUtils (
getScriptHash, getScriptCacheDirectory, ensureScriptCacheDirectory,
withContextAndSelectors, AcceptNoTargets(..), TargetContext(..),
updateContextAndWriteProjectFile, updateContextAndWriteProjectFile',
fakeProjectSourcePackage, lSrcpkgDescription,
movedExePath
) where
import Prelude ()
import Distribution.Client.Compat.Prelude hiding (toList)
import Distribution.Compat.Lens
import qualified Distribution.Types.Lens as L
import Distribution.CabalSpecVersion
( CabalSpecVersion (..), cabalSpecLatest)
import Distribution.Client.ProjectOrchestration
import Distribution.Client.Config
( defaultScriptBuildsDir )
import Distribution.Client.DistDirLayout
( DistDirLayout(..), DistDirParams(..) )
import Distribution.Client.HashValue
( hashValue, showHashValueBase64 )
import Distribution.Client.HttpUtils
( HttpTransport, configureTransport )
import Distribution.Client.NixStyleOptions
( NixStyleFlags (..) )
import Distribution.Client.ProjectConfig
( ProjectConfig(..), ProjectConfigShared(..), PackageConfig(..)
, reportParseResult, withGlobalConfig, withProjectOrGlobalConfig
, projectConfigHttpTransport )
import Distribution.Client.ProjectConfig.Legacy
( ProjectConfigSkeleton
, parseProjectSkeleton, instantiateProjectConfigSkeletonFetchingCompiler )
import Distribution.Client.ProjectFlags
( flagIgnoreProject )
import Distribution.Client.ProjectPlanning
( ElaboratedSharedConfig(..), ElaboratedConfiguredPackage(..) )
import Distribution.Client.RebuildMonad
( runRebuild )
import Distribution.Client.Setup
( ConfigFlags(..), GlobalFlags(..) )
import Distribution.Client.TargetSelector
( TargetSelectorProblem(..), TargetString(..) )
import Distribution.Client.Types
( PackageLocation(..), PackageSpecifier(..), UnresolvedSourcePackage )
import Distribution.Compiler
( CompilerId(..), perCompilerFlavorToList )
import Distribution.FieldGrammar
( parseFieldGrammar, takeFields )
import Distribution.Fields
( ParseResult, parseFatalFailure, readFields )
import Distribution.PackageDescription
( ignoreConditions )
import Distribution.PackageDescription.FieldGrammar
( executableFieldGrammar )
import Distribution.PackageDescription.PrettyPrint
( showGenericPackageDescription )
import Distribution.Parsec
( Position(..) )
import Distribution.Simple.Flag
( fromFlagOrDefault, flagToMaybe )
import Distribution.Simple.PackageDescription
( parseString )
import Distribution.Simple.Setup
( Flag(..) )
import Distribution.Simple.Compiler
( Compiler(..), OptimisationLevel(..), compilerInfo )
import Distribution.Simple.Utils
( createDirectoryIfMissingVerbose, createTempDirectory, die', handleDoesNotExist, readUTF8File, warn, writeUTF8File )
import qualified Distribution.SPDX.License as SPDX
import Distribution.Solver.Types.SourcePackage as SP
( SourcePackage(..) )
import Distribution.System
( Platform(..) )
import Distribution.Types.BuildInfo
( BuildInfo(..) )
import Distribution.Types.ComponentId
( mkComponentId )
import Distribution.Types.CondTree
( CondTree(..) )
import Distribution.Types.Executable
( Executable(..) )
import Distribution.Types.GenericPackageDescription as GPD
( GenericPackageDescription(..), emptyGenericPackageDescription )
import Distribution.Types.PackageDescription
( PackageDescription(..), emptyPackageDescription )
import Distribution.Types.PackageName.Magic
( fakePackageCabalFileName, fakePackageId )
import Distribution.Types.UnitId
( newSimpleUnitId )
import Distribution.Types.UnqualComponentName
( UnqualComponentName )
import Distribution.Utils.NubList
( fromNubList )
import Distribution.Client.ProjectPlanning
( configureCompiler )
import Distribution.Verbosity
( normal )
import Language.Haskell.Extension
( Language(..) )
import Control.Concurrent.MVar
( newEmptyMVar, putMVar, tryTakeMVar )
import Control.Exception
( bracket )
import qualified Data.ByteString.Char8 as BS
import Data.ByteString.Lazy ()
import qualified Data.Set as S
import System.Directory
( canonicalizePath, doesFileExist, getTemporaryDirectory, removeDirectoryRecursive )
import System.FilePath
( (</>), makeRelative, takeDirectory, takeFileName )
import qualified Text.Parsec as P
-- A note on multi-module script support #6787:
-- Multi-module scripts are not supported and support is non-trivial.
-- What you want to do is pass the absolute path to the script's directory in hs-source-dirs,
-- but hs-source-dirs only accepts relative paths. This leaves you with several options none
-- of which are particularly appealing.
-- 1) Loosen the requirement that hs-source-dirs take relative paths
-- 2) Add a field to BuildInfo that acts like an hs-source-dir, but accepts an absolute path
-- 3) Use a path relative to the project root in hs-source-dirs, and pass extra flags to the
-- repl to deal with the fact that the repl is relative to the working directory and not
-- the project root.
-- | Get the hash of a script's absolute path)
--
-- Two hashes will be the same as long as the absolute paths
-- are the same.
getScriptHash :: FilePath -> IO String
getScriptHash script
-- Base64 is shorter than Base16, which helps avoid long path issues on windows
-- but it can contain /'s which aren't valid in file paths so replace them with
-- %'s. 26 chars / 130 bits is enough to practically avoid collisions.
= map (\c -> if c == '/' then '%' else c) . take 26
. showHashValueBase64 . hashValue . fromString <$> canonicalizePath script
-- | Get the directory for caching a script build.
--
-- The only identity of a script is it's absolute path, so append the
-- hashed path to the @script-builds@ dir to get the cache directory.
getScriptCacheDirectory :: FilePath -> IO FilePath
getScriptCacheDirectory script = (</>) <$> defaultScriptBuildsDir <*> getScriptHash script
-- | Get the directory for caching a script build and ensure it exists.
--
-- The only identity of a script is it's absolute path, so append the
-- hashed path to the @script-builds@ dir to get the cache directory.
ensureScriptCacheDirectory :: Verbosity -> FilePath -> IO FilePath
ensureScriptCacheDirectory verbosity script = do
cacheDir <- getScriptCacheDirectory script
createDirectoryIfMissingVerbose verbosity True cacheDir
return cacheDir
-- | What your command should do when no targets are found.
data AcceptNoTargets
= RejectNoTargets -- ^ die on 'TargetSelectorNoTargetsInProject'
| AcceptNoTargets -- ^ return a default 'TargetSelector'
deriving (Eq, Show)
-- | Information about the context in which we found the 'TargetSelector's.
data TargetContext
= ProjectContext -- ^ The target selectors are part of a project.
| GlobalContext -- ^ The target selectors are from the global context.
| ScriptContext FilePath Executable
-- ^ The target selectors refer to a script. Contains the path to the script and
-- the executable metadata parsed from the script
deriving (Eq, Show)
-- | Determine whether the targets represent regular targets or a script
-- and return the proper context and target selectors.
-- Die with an error message if selectors are valid as neither regular targets or as a script.
--
-- In the case that the context refers to a temporary directory,
-- delete it after the action finishes.
withContextAndSelectors
:: AcceptNoTargets -- ^ What your command should do when no targets are found.
-> Maybe ComponentKind -- ^ A target filter
-> NixStyleFlags a -- ^ Command line flags
-> [String] -- ^ Target strings or a script and args.
-> GlobalFlags -- ^ Global flags.
-> CurrentCommand -- ^ Current Command (usually for error reporting).
-> (TargetContext -> ProjectBaseContext -> [TargetSelector] -> IO b)
-- ^ The body of your command action.
-> IO b
withContextAndSelectors noTargets kind flags@NixStyleFlags {..} targetStrings globalFlags cmd act
= withTemporaryTempDirectory $ \mkTmpDir -> do
(tc, ctx) <- withProjectOrGlobalConfig verbosity ignoreProject globalConfigFlag withProject (withoutProject mkTmpDir)
(tc', ctx', sels) <- case targetStrings of
-- Only script targets may contain spaces and or end with ':'.
-- Trying to readTargetSelectors such a target leads to a parse error.
[target] | any (\c -> isSpace c) target || ":" `isSuffixOf` target -> do
scriptOrError target [TargetSelectorNoScript $ TargetString1 target]
_ -> do
-- In the case where a selector is both a valid target and script, assume it is a target,
-- because you can disambiguate the script with "./script"
readTargetSelectors (localPackages ctx) kind targetStrings >>= \case
Left err@(TargetSelectorNoTargetsInProject:_)
| [] <- targetStrings
, AcceptNoTargets <- noTargets -> return (tc, ctx, defaultTarget)
| (script:_) <- targetStrings -> scriptOrError script err
Left err@(TargetSelectorNoSuch t _:_)
| TargetString1 script <- t -> scriptOrError script err
Left err@(TargetSelectorExpected t _ _:_)
| TargetString1 script <- t -> scriptOrError script err
Left err@(MatchingInternalError _ _ _:_) -- Handle ':' in middle of script name.
| [script] <- targetStrings -> scriptOrError script err
Left err -> reportTargetSelectorProblems verbosity err
Right sels -> return (tc, ctx, sels)
act tc' ctx' sels
where
verbosity = fromFlagOrDefault normal (configVerbosity configFlags)
ignoreProject = flagIgnoreProject projectFlags
cliConfig = commandLineFlagsToProjectConfig globalFlags flags mempty
globalConfigFlag = projectConfigConfigFile (projectConfigShared cliConfig)
defaultTarget = [TargetPackage TargetExplicitNamed [fakePackageId] Nothing]
withProject = do
ctx <- establishProjectBaseContext verbosity cliConfig cmd
return (ProjectContext, ctx)
withoutProject mkTmpDir globalConfig = do
distDirLayout <- establishDummyDistDirLayout verbosity (globalConfig <> cliConfig) =<< mkTmpDir
ctx <- establishDummyProjectBaseContext verbosity (globalConfig <> cliConfig) distDirLayout [] cmd
return (GlobalContext, ctx)
scriptBaseCtx script globalConfig = do
let noDistDir = mempty { projectConfigShared = mempty { projectConfigDistDir = Flag "" } }
let cfg = noDistDir <> globalConfig <> cliConfig
rootDir <- ensureScriptCacheDirectory verbosity script
distDirLayout <- establishDummyDistDirLayout verbosity cfg rootDir
establishDummyProjectBaseContext verbosity cfg distDirLayout [] cmd
scriptOrError script err = do
exists <- doesFileExist script
if exists then do
ctx <- withGlobalConfig verbosity globalConfigFlag (scriptBaseCtx script)
let projectRoot = distProjectRootDirectory $ distDirLayout ctx
writeFile (projectRoot </> "scriptlocation") =<< canonicalizePath script
scriptContents <- BS.readFile script
executable <- readExecutableBlockFromScript verbosity scriptContents
httpTransport <- configureTransport verbosity
(fromNubList . projectConfigProgPathExtra $ projectConfigShared cliConfig)
(flagToMaybe . projectConfigHttpTransport $ projectConfigBuildOnly cliConfig)
projectCfgSkeleton <- readProjectBlockFromScript verbosity httpTransport (distDirLayout ctx) (takeFileName script) scriptContents
createDirectoryIfMissingVerbose verbosity True (distProjectCacheDirectory $ distDirLayout ctx)
(compiler, platform@(Platform arch os), _) <- runRebuild projectRoot $ configureCompiler verbosity (distDirLayout ctx) (fst (ignoreConditions projectCfgSkeleton) <> projectConfig ctx)
projectCfg <- instantiateProjectConfigSkeletonFetchingCompiler (pure (os, arch, compilerInfo compiler)) mempty projectCfgSkeleton
let ctx' = ctx & lProjectConfig %~ (<> projectCfg)
build_dir = distBuildDirectory (distDirLayout ctx') $ (scriptDistDirParams script) ctx' compiler platform
exePath = build_dir </> "bin" </> scriptExeFileName script
exePathRel = makeRelative projectRoot exePath
executable' = executable & L.buildInfo . L.defaultLanguage %~ maybe (Just Haskell2010) Just
& L.buildInfo . L.options %~ fmap (setExePath exePathRel)
createDirectoryIfMissingVerbose verbosity True (takeDirectory exePath)
return (ScriptContext script executable', ctx', defaultTarget)
else reportTargetSelectorProblems verbosity err
withTemporaryTempDirectory :: (IO FilePath -> IO a) -> IO a
withTemporaryTempDirectory act = newEmptyMVar >>= \m -> bracket (getMkTmp m) (rmTmp m) act
where
-- We return an (IO Filepath) instead of a FilePath for two reasons:
-- 1) To give the consumer the discretion to not create the tmpDir,
-- but still grantee that it's deleted if they do create it
-- 2) Because the path returned by createTempDirectory is not predicable
getMkTmp m = return $ do
tmpDir <- getTemporaryDirectory >>= flip createTempDirectory "cabal-repl."
putMVar m tmpDir
return tmpDir
rmTmp m _ = tryTakeMVar m >>= maybe (return ()) (handleDoesNotExist () . removeDirectoryRecursive)
scriptComponenetName :: IsString s => FilePath -> s
scriptComponenetName scriptPath = fromString cname
where
cname = "script-" ++ map censor (takeFileName scriptPath)
censor c | c `S.member` ccNamecore = c
| otherwise = '_'
scriptExeFileName :: FilePath -> FilePath
scriptExeFileName scriptPath = "cabal-script-" ++ takeFileName scriptPath
scriptDistDirParams :: FilePath -> ProjectBaseContext -> Compiler -> Platform -> DistDirParams
scriptDistDirParams scriptPath ctx compiler platform = DistDirParams
{ distParamUnitId = newSimpleUnitId cid
, distParamPackageId = fakePackageId
, distParamComponentId = cid
, distParamComponentName = Just $ CExeName cn
, distParamCompilerId = compilerId compiler
, distParamPlatform = platform
, distParamOptimization = fromFlagOrDefault NormalOptimisation optimization
}
where
cn = scriptComponenetName scriptPath
cid = mkComponentId $ prettyShow fakePackageId <> "-inplace-" <> prettyShow cn
optimization = (packageConfigOptimization . projectConfigLocalPackages . projectConfig) ctx
setExePath :: FilePath -> [String] -> [String]
setExePath exePath options
| "-o" `notElem` options = "-o" : exePath : options
| otherwise = options
-- | Add the 'SourcePackage' to the context and use it to write a .cabal file.
updateContextAndWriteProjectFile' :: ProjectBaseContext -> SourcePackage (PackageLocation (Maybe FilePath)) -> IO ProjectBaseContext
updateContextAndWriteProjectFile' ctx srcPkg = do
let projectRoot = distProjectRootDirectory $ distDirLayout ctx
packageFile = projectRoot </> fakePackageCabalFileName
contents = showGenericPackageDescription (srcpkgDescription srcPkg)
writePackageFile = writeUTF8File packageFile contents
-- TODO This is here to prevent reconfiguration of cached repl packages.
-- It's worth investigating why it's needed in the first place.
packageFileExists <- doesFileExist packageFile
if packageFileExists then do
cached <- force <$> readUTF8File packageFile
when (cached /= contents)
writePackageFile
else writePackageFile
return (ctx & lLocalPackages %~ (++ [SpecificSourcePackage srcPkg]))
-- | Add add the executable metadata to the context and write a .cabal file.
updateContextAndWriteProjectFile :: ProjectBaseContext -> FilePath -> Executable -> IO ProjectBaseContext
updateContextAndWriteProjectFile ctx scriptPath scriptExecutable = do
let projectRoot = distProjectRootDirectory $ distDirLayout ctx
absScript <- canonicalizePath scriptPath
let
sourcePackage = fakeProjectSourcePackage projectRoot
& lSrcpkgDescription . L.condExecutables
.~ [(scriptComponenetName scriptPath, CondNode executable (targetBuildDepends $ buildInfo executable) [])]
executable = scriptExecutable
& L.modulePath .~ absScript
updateContextAndWriteProjectFile' ctx sourcePackage
parseScriptBlock :: BS.ByteString -> ParseResult Executable
parseScriptBlock str =
case readFields str of
Right fs -> do
let (fields, _) = takeFields fs
parseFieldGrammar cabalSpecLatest fields (executableFieldGrammar "script")
Left perr -> parseFatalFailure pos (show perr) where
ppos = P.errorPos perr
pos = Position (P.sourceLine ppos) (P.sourceColumn ppos)
readScriptBlock :: Verbosity -> BS.ByteString -> IO Executable
readScriptBlock verbosity = parseString parseScriptBlock verbosity "script block"
-- | Extract the first encountered executable metadata block started and
-- terminated by the below tokens or die.
--
-- * @{- cabal:@
--
-- * @-}@
--
-- Return the metadata.
readExecutableBlockFromScript :: Verbosity -> BS.ByteString -> IO Executable
readExecutableBlockFromScript verbosity str = do
str' <- case extractScriptBlock "cabal" str of
Left e -> die' verbosity $ "Failed extracting script block: " ++ e
Right x -> return x
when (BS.all isSpace str') $ warn verbosity "Empty script block"
readScriptBlock verbosity str'
-- | Extract the first encountered project metadata block started and
-- terminated by the below tokens.
--
-- * @{- project:@
--
-- * @-}@
--
-- Return the metadata.
readProjectBlockFromScript :: Verbosity -> HttpTransport -> DistDirLayout -> String -> BS.ByteString -> IO ProjectConfigSkeleton
readProjectBlockFromScript verbosity httpTransport DistDirLayout{distDownloadSrcDirectory} scriptName str = do
case extractScriptBlock "project" str of
Left _ -> return mempty
Right x -> reportParseResult verbosity "script" scriptName
=<< parseProjectSkeleton distDownloadSrcDirectory httpTransport verbosity [] scriptName x
-- | Extract the first encountered script metadata block started end
-- terminated by the tokens
--
-- * @{- <header>:@
--
-- * @-}@
--
-- appearing alone on lines (while tolerating trailing whitespace).
-- These tokens are not part of the 'Right' result.
--
-- In case of missing or unterminated blocks a 'Left'-error is
-- returned.
extractScriptBlock :: BS.ByteString -> BS.ByteString -> Either String BS.ByteString
extractScriptBlock header str = goPre (BS.lines str)
where
isStartMarker = (== startMarker) . stripTrailSpace
isEndMarker = (== endMarker) . stripTrailSpace
stripTrailSpace = fst . BS.spanEnd isSpace
-- before start marker
goPre ls = case dropWhile (not . isStartMarker) ls of
[] -> Left $ "`" ++ BS.unpack startMarker ++ "` start marker not found"
(_:ls') -> goBody [] ls'
goBody _ [] = Left $ "`" ++ BS.unpack endMarker ++ "` end marker not found"
goBody acc (l:ls)
| isEndMarker l = Right $! BS.unlines $ reverse acc
| otherwise = goBody (l:acc) ls
startMarker, endMarker :: BS.ByteString
startMarker = "{- " <> header <> ":"
endMarker = "-}"
-- | The base for making a 'SourcePackage' for a fake project.
-- It needs a 'Distribution.Types.Library.Library' or 'Executable' depending on the command.
fakeProjectSourcePackage :: FilePath -> SourcePackage (PackageLocation loc)
fakeProjectSourcePackage projectRoot = sourcePackage
where
sourcePackage = SourcePackage
{ srcpkgPackageId = fakePackageId
, srcpkgDescription = genericPackageDescription
, srcpkgSource = LocalUnpackedPackage projectRoot
, srcpkgDescrOverride = Nothing
}
genericPackageDescription = emptyGenericPackageDescription
{ GPD.packageDescription = packageDescription }
packageDescription = emptyPackageDescription
{ package = fakePackageId
, specVersion = CabalSpecV2_2
, licenseRaw = Left SPDX.NONE
}
-- | Find the path of an exe that has been relocated with a "-o" option
movedExePath :: UnqualComponentName -> DistDirLayout -> ElaboratedSharedConfig -> ElaboratedConfiguredPackage -> Maybe FilePath
movedExePath selectedComponent distDirLayout elabShared elabConfigured = do
exe <- find ((== selectedComponent) . exeName) . executables $ elabPkgDescription elabConfigured
let CompilerId flavor _ = (compilerId . pkgConfigCompiler) elabShared
opts <- lookup flavor (perCompilerFlavorToList . options $ buildInfo exe)
let projectRoot = distProjectRootDirectory distDirLayout
fmap (projectRoot </>) . lookup "-o" $ reverse (zip opts (drop 1 opts))
-- Lenses
-- | A lens for the 'srcpkgDescription' field of 'SourcePackage'
lSrcpkgDescription :: Lens' (SourcePackage loc) GenericPackageDescription
lSrcpkgDescription f s = fmap (\x -> s { srcpkgDescription = x }) (f (srcpkgDescription s))
{-# inline lSrcpkgDescription #-}
lLocalPackages :: Lens' ProjectBaseContext [PackageSpecifier UnresolvedSourcePackage]
lLocalPackages f s = fmap (\x -> s { localPackages = x }) (f (localPackages s))
{-# inline lLocalPackages #-}
lProjectConfig :: Lens' ProjectBaseContext ProjectConfig
lProjectConfig f s = fmap (\x -> s { projectConfig = x }) (f (projectConfig s))
{-# inline lProjectConfig #-}
-- Character classes
-- Transcribed from "templates/Lexer.x"
ccSpace, ccCtrlchar, ccPrintable, ccSymbol', ccParen, ccNamecore :: Set Char
ccSpace = S.fromList " "
ccCtrlchar = S.fromList $ [chr 0x0 .. chr 0x1f] ++ [chr 0x7f]
ccPrintable = S.fromList [chr 0x0 .. chr 0xff] S.\\ ccCtrlchar
ccSymbol' = S.fromList ",=<>+*&|!$%^@#?/\\~"
ccParen = S.fromList "()[]"
ccNamecore = ccPrintable S.\\ S.unions [ccSpace, S.fromList ":\"{}", ccParen, ccSymbol']