stack-2.11.1: src/Stack/Script.hs
{-# LANGUAGE NoImplicitPrelude #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
-- Types and functions related to Stack's @script@ command.
module Stack.Script
( ScriptOpts (..)
, ScriptExecute (..)
, ShouldRun (..)
, scriptCmd
) where
import Data.ByteString.Builder ( toLazyByteString )
import qualified Data.ByteString.Char8 as S8
import qualified Data.Conduit.List as CL
import Data.List.Split ( splitWhen )
import qualified Data.Map.Strict as Map
import qualified Data.Set as Set
import Distribution.Compiler ( CompilerFlavor (..) )
import Distribution.ModuleName ( ModuleName )
import qualified Distribution.PackageDescription as PD
import qualified Distribution.Types.CondTree as C
import Distribution.Types.ModuleReexport ( moduleReexportName )
import Distribution.Types.PackageName ( mkPackageName )
import Distribution.Types.VersionRange ( withinRange )
import Distribution.System ( Platform (..) )
import qualified Pantry.SHA256 as SHA256
import Path
( (</>), filename, fromAbsDir, fromAbsFile, fromRelFile
, parent, parseRelDir, replaceExtension, splitExtension
)
import Path.IO ( getModificationTime, resolveFile' )
import qualified RIO.Directory as Dir
import RIO.Process ( exec, proc, readProcessStdout_, withWorkingDir )
import qualified RIO.Text as T
import Stack.Build ( build )
import Stack.Build.Installed ( getInstalled, toInstallMap )
import Stack.Constants ( osIsWindows, relDirScripts )
import Stack.Prelude
import Stack.Runners
( ShouldReexec (..), withConfig, withDefaultEnvConfig )
import Stack.Setup ( withNewLocalBuildTargets )
import Stack.SourceMap ( getCompilerInfo, immutableLocSha )
import Stack.Types.Compiler ( ActualCompiler (..) )
import Stack.Types.CompilerPaths
( CompilerPaths (..), GhcPkgExe (..), HasCompiler (..) )
import Stack.Types.Config ( Config (..), HasConfig (..), stackRootL )
import Stack.Types.ConfigMonoid ( ConfigMonoid (..) )
import Stack.Types.DumpPackage ( DumpPackage (..) )
import Stack.Types.EnvConfig
( EnvConfig (..), HasEnvConfig (..), actualCompilerVersionL
, appropriateGhcColorFlag
)
import Stack.Types.EnvSettings ( defaultEnvSettings )
import Stack.Types.GlobalOpts ( GlobalOpts (..) )
import Stack.Types.Platform ( HasPlatform (..) )
import Stack.Types.Runner ( Runner, globalOptsL )
import Stack.Types.SourceMap
( CommonPackage (..), DepPackage (..), SourceMap (..) )
import Stack.Types.StackYamlLoc ( StackYamlLoc (..) )
import System.FilePath ( splitDrive )
-- | Type representing exceptions thrown by functions exported by the
-- "Stack.Script" module.
data ScriptException
= MutableDependenciesForScript [PackageName]
| AmbiguousModuleName ModuleName [PackageName]
| ArgumentsWithNoRunInvalid
| NoRunWithoutCompilationInvalid
| FailedToParseScriptFileAsDirBug (Path Rel File)
| FailedToParseFileAsDirBug (Path Abs Dir)
deriving (Show, Typeable)
instance Exception ScriptException where
displayException (MutableDependenciesForScript names) = unlines
$ "Error: [S-4994]"
: "No mutable packages are allowed in the 'script' command. Mutable \
\packages found:"
: map (\name -> "- " ++ packageNameString name) names
displayException (AmbiguousModuleName mname pkgs) = unlines
$ "Error: [S-1691]"
: ( "Module "
++ moduleNameString mname
++ " appears in multiple packages: "
)
: [ unwords $ map packageNameString pkgs ]
displayException ArgumentsWithNoRunInvalid =
"Error: [S-5067]\n"
++ "'--no-run' incompatible with arguments."
displayException NoRunWithoutCompilationInvalid =
"Error: [S-9469]\n"
++ "'--no-run' requires either '--compile' or '--optimize'."
displayException (FailedToParseScriptFileAsDirBug fp) = bugReport "[S-5055]" $
"Failed to parse script file name as directory:\n"
<> fromRelFile fp <> "\n"
displayException (FailedToParseFileAsDirBug p) = bugReport "[S-9464]" $
"Failed to parse path to script file as directory:\n"
<> fromAbsDir p <> "\n"
-- | Type representing choices of interpreting, compiling (without optimisation)
-- and compiling (with optimisation).
data ScriptExecute
= SEInterpret
| SECompile
-- ^ Without optimisation.
| SEOptimize
-- ^ Compile with optimisation.
deriving Show
-- | Type representing choices of whether to run or not.
data ShouldRun
= YesRun
-- ^ Run.
| NoRun
-- ^ Do not run.
deriving Show
-- | Type representing command line options for the @stack script@ command.
data ScriptOpts = ScriptOpts
{ soPackages :: ![String]
, soFile :: !FilePath
, soArgs :: ![String]
, soCompile :: !ScriptExecute
, soUseRoot :: !Bool
, soGhcOptions :: ![String]
, soScriptExtraDeps :: ![PackageIdentifierRevision]
, soShouldRun :: !ShouldRun
}
deriving Show
-- | Run a Stack Script
scriptCmd :: ScriptOpts -> RIO Runner ()
scriptCmd opts = do
-- Some warnings in case the user somehow tries to set a stack.yaml location.
-- Note that in this functions we use logError instead of logWarn because,
-- when using the interpreter mode, only error messages are shown. See:
-- https://github.com/commercialhaskell/stack/issues/3007
view (globalOptsL.to globalStackYaml) >>= \case
SYLOverride fp -> logError $
"Ignoring override stack.yaml file for script command: "
<> fromString (toFilePath fp)
SYLGlobalProject -> logError "Ignoring SYLGlobalProject for script command"
SYLDefault -> pure ()
SYLNoProject _ -> assert False (pure ())
file <- resolveFile' $ soFile opts
let scriptFile = filename file
isNoRunCompile <- fromFirstFalse . configMonoidNoRunCompile <$>
view (globalOptsL.to globalConfigMonoid)
let scriptDir = parent file
modifyGO go = go
{ globalConfigMonoid = (globalConfigMonoid go)
{ configMonoidInstallGHC = FirstTrue $ Just True
}
, globalStackYaml = SYLNoProject $ soScriptExtraDeps opts
}
(shouldRun, shouldCompile) = if isNoRunCompile
then (NoRun, SECompile)
else (soShouldRun opts, soCompile opts)
root <- withConfig NoReexec $ view stackRootL
outputDir <- if soUseRoot opts
then do
scriptFileAsDir <- maybe
(throwIO $ FailedToParseScriptFileAsDirBug scriptFile)
pure
(parseRelDir $ fromRelFile scriptFile)
let fileAsDir = scriptDir </> scriptFileAsDir
-- We drop the information about the drive. On Windows, in principle,
-- the drive could distinguish between two otherwise identical
-- fileAsDir (eg C:\MyScript.hs\ D:\MyScript.hs\). In pactice, we
-- tolerate that possibility as being unlikely.
(_, escaped) = splitDrive (fromAbsDir fileAsDir)
escapedRelDir <- maybe
(throwIO $ FailedToParseFileAsDirBug fileAsDir)
pure
(parseRelDir escaped)
pure $ root </> relDirScripts </> escapedRelDir
else pure scriptDir
let dropExtension path = fst <$> splitExtension path
exe <- if osIsWindows
then replaceExtension ".exe" (outputDir </> scriptFile)
else dropExtension (outputDir </> scriptFile)
case shouldRun of
YesRun -> pure ()
NoRun -> do
unless (null $ soArgs opts) $ throwIO ArgumentsWithNoRunInvalid
case shouldCompile of
SEInterpret -> throwIO NoRunWithoutCompilationInvalid
SECompile -> pure ()
SEOptimize -> pure ()
-- Optimization: if we're compiling, and the executable is newer than the
-- source file, run it immediately.
local (over globalOptsL modifyGO) $
case shouldCompile of
SEInterpret -> longWay shouldRun shouldCompile file exe
SECompile -> shortCut shouldRun shouldCompile file exe
SEOptimize -> shortCut shouldRun shouldCompile file exe
where
runCompiled shouldRun exe = do
case shouldRun of
YesRun -> exec (fromAbsFile exe) (soArgs opts)
NoRun -> prettyInfoL
[ flow "Compilation finished, executable available at"
, style File (fromString (fromAbsFile exe)) <> "."
]
shortCut shouldRun shouldCompile file exe =
handleIO (const $ longWay shouldRun shouldCompile file exe) $ do
srcMod <- getModificationTime file
exeMod <- Dir.getModificationTime (fromAbsFile exe)
if srcMod < exeMod
then runCompiled shouldRun exe
else longWay shouldRun shouldCompile file exe
longWay shouldRun shouldCompile file exe =
withConfig YesReexec $
withDefaultEnvConfig $ do
config <- view configL
menv <- liftIO $ configProcessContextSettings config defaultEnvSettings
withProcessContext menv $ do
colorFlag <- appropriateGhcColorFlag
targetsSet <-
case soPackages opts of
[] -> getPackagesFromImports (soFile opts) -- Using the import parser
packages -> do
let targets = concatMap wordsComma packages
targets' <- mapM parsePackageNameThrowing targets
pure $ Set.fromList targets'
unless (Set.null targetsSet) $ do
-- Optimization: use the relatively cheap ghc-pkg list --simple-output
-- to check which packages are installed already. If all needed
-- packages are available, we can skip the (rather expensive) build
-- call below.
GhcPkgExe pkg <- view $ compilerPathsL.to cpPkg
-- https://github.com/haskell/process/issues/251
bss <- snd <$> sinkProcessStderrStdout (toFilePath pkg)
["list", "--simple-output"] CL.sinkNull CL.consume -- FIXME use the package info from envConfigPackages, or is that crazy?
let installed = Set.fromList
$ map toPackageName
$ words
$ S8.unpack
$ S8.concat bss
if Set.null $ Set.difference (Set.map packageNameString targetsSet) installed
then logDebug "All packages already installed"
else do
logDebug "Missing packages, performing installation"
let targets =
map (T.pack . packageNameString) $ Set.toList targetsSet
withNewLocalBuildTargets targets $ build Nothing
let ghcArgs = concat
[ ["-i", "-i" ++ fromAbsDir (parent file)]
, ["-hide-all-packages"]
, maybeToList colorFlag
, map ("-package" ++)
$ Set.toList
$ Set.insert "base"
$ Set.map packageNameString targetsSet
, case shouldCompile of
SEInterpret -> []
SECompile -> []
SEOptimize -> ["-O2"]
, soGhcOptions opts
, if soUseRoot opts
then
[ "-outputdir=" ++ fromAbsDir (parent exe)
, "-o", fromAbsFile exe
]
else []
]
case shouldCompile of
SEInterpret -> do
interpret <- view $ compilerPathsL.to cpInterpreter
exec (toFilePath interpret)
(ghcArgs ++ toFilePath file : soArgs opts)
_ -> do
-- Use readProcessStdout_ so that (1) if GHC does send any output
-- to stdout, we capture it and stop it from being sent to our
-- stdout, which could break scripts, and (2) if there's an
-- exception, the standard output we did capture will be reported
-- to the user.
liftIO $ Dir.createDirectoryIfMissing True (fromAbsDir (parent exe))
compilerExeName <- view $ compilerPathsL.to cpCompiler.to toFilePath
withWorkingDir (fromAbsDir (parent file)) $ proc
compilerExeName
(ghcArgs ++ [toFilePath file])
(void . readProcessStdout_)
runCompiled shouldRun exe
toPackageName = reverse . drop 1 . dropWhile (/= '-') . reverse
-- Like words, but splits on both commas and spaces
wordsComma = splitWhen (\c -> c == ' ' || c == ',')
getPackagesFromImports ::
FilePath -- ^ script filename
-> RIO EnvConfig (Set PackageName)
getPackagesFromImports scriptFP = do
(pns, mns) <- liftIO $ parseImports <$> S8.readFile scriptFP
if Set.null mns
then pure pns
else Set.union pns <$> getPackagesFromModuleNames mns
getPackagesFromModuleNames ::
Set ModuleName
-> RIO EnvConfig (Set PackageName)
getPackagesFromModuleNames mns = do
hash <- hashSnapshot
withSnapshotCache hash mapSnapshotPackageModules $ \getModulePackages -> do
pns <- forM (Set.toList mns) $ \mn -> do
pkgs <- getModulePackages mn
case pkgs of
[] -> pure Set.empty
[pn] -> pure $ Set.singleton pn
_ -> throwM $ AmbiguousModuleName mn pkgs
pure $ Set.unions pns `Set.difference` blacklist
hashSnapshot :: RIO EnvConfig SnapshotCacheHash
hashSnapshot = do
sourceMap <- view $ envConfigL . to envConfigSourceMap
compilerInfo <- getCompilerInfo
let eitherPliHash (pn, dep)
| PLImmutable pli <- dpLocation dep = Right $ immutableLocSha pli
| otherwise = Left pn
deps = Map.toList (smDeps sourceMap)
case partitionEithers (map eitherPliHash deps) of
([], pliHashes) -> do
let hashedContent = mconcat $ compilerInfo : pliHashes
pure
$ SnapshotCacheHash (SHA256.hashLazyBytes
$ toLazyByteString hashedContent)
(mutables, _) -> throwM $ MutableDependenciesForScript mutables
mapSnapshotPackageModules :: RIO EnvConfig (Map PackageName (Set ModuleName))
mapSnapshotPackageModules = do
sourceMap <- view $ envConfigL . to envConfigSourceMap
installMap <- toInstallMap sourceMap
(_installedMap, globalDumpPkgs, snapshotDumpPkgs, _localDumpPkgs) <-
getInstalled installMap
let globals = dumpedPackageModules (smGlobal sourceMap) globalDumpPkgs
notHidden = Map.filter (not . dpHidden)
notHiddenDeps = notHidden $ smDeps sourceMap
installedDeps = dumpedPackageModules notHiddenDeps snapshotDumpPkgs
dumpPkgs = Set.fromList $ map (pkgName . dpPackageIdent) snapshotDumpPkgs
notInstalledDeps = Map.withoutKeys notHiddenDeps dumpPkgs
otherDeps <- for notInstalledDeps $ \dep -> do
gpd <- liftIO $ cpGPD (dpCommon dep)
Set.fromList <$> allExposedModules gpd
-- source map construction process should guarantee unique package names in
-- these maps
pure $ globals <> installedDeps <> otherDeps
dumpedPackageModules ::
Map PackageName a
-> [DumpPackage]
-> Map PackageName (Set ModuleName)
dumpedPackageModules pkgs dumpPkgs =
let pnames = Map.keysSet pkgs `Set.difference` blacklist
in Map.fromList
[ (pn, dpExposedModules)
| DumpPackage {..} <- dumpPkgs
, let PackageIdentifier pn _ = dpPackageIdent
, pn `Set.member` pnames
]
allExposedModules :: PD.GenericPackageDescription -> RIO EnvConfig [ModuleName]
allExposedModules gpd = do
Platform curArch curOs <- view platformL
curCompiler <- view actualCompilerVersionL
let checkCond (PD.OS os) = pure $ os == curOs
checkCond (PD.Arch arch) = pure $ arch == curArch
checkCond (PD.Impl compiler range) = case curCompiler of
ACGhc version ->
pure $ compiler == GHC && version `withinRange` range
ACGhcGit {} ->
pure $ compiler == GHC
-- currently we don't do flag checking here
checkCond other = Left other
mlibrary = snd . C.simplifyCondTree checkCond <$> PD.condLibrary gpd
pure $ case mlibrary of
Just lib -> PD.exposedModules lib ++
map moduleReexportName (PD.reexportedModules lib)
Nothing -> mempty
-- | The Stackage project introduced the concept of hidden packages, to deal
-- with conflicting module names. However, this is a relatively recent addition
-- (at time of writing). See:
-- http://www.snoyman.com/blog/2017/01/conflicting-module-names. To kick this
-- thing off a bit better, we're included a blacklist of packages that should
-- never be auto-parsed in.
blacklist :: Set PackageName
blacklist = Set.fromList
[ mkPackageName "Glob"
, mkPackageName "HTF"
, mkPackageName "async-dejafu"
, mkPackageName "binary-ieee754"
, mkPackageName "cipher-aes"
, mkPackageName "cipher-blowfish"
, mkPackageName "cipher-camellia"
, mkPackageName "cipher-des"
, mkPackageName "cipher-rc4"
, mkPackageName "control-monad-free"
, mkPackageName "courier"
, mkPackageName "crypto-api"
, mkPackageName "crypto-cipher-types"
, mkPackageName "crypto-numbers"
, mkPackageName "crypto-pubkey"
, mkPackageName "crypto-random"
, mkPackageName "cryptohash"
, mkPackageName "cryptohash-conduit"
, mkPackageName "cryptohash-md5"
, mkPackageName "cryptohash-sha1"
, mkPackageName "cryptohash-sha256"
, mkPackageName "fay-base"
, mkPackageName "gl"
, mkPackageName "gtk3"
, mkPackageName "hashmap"
, mkPackageName "hledger-web"
, mkPackageName "hxt-unicode"
, mkPackageName "kawhi"
, mkPackageName "language-c"
, mkPackageName "log"
, mkPackageName "monad-extras"
, mkPackageName "monads-tf"
, mkPackageName "nanospec"
, mkPackageName "newtype-generics"
, mkPackageName "objective"
, mkPackageName "plot-gtk3"
, mkPackageName "prompt"
, mkPackageName "regex-compat-tdfa"
, mkPackageName "regex-pcre-builtin"
, mkPackageName "rerebase"
, mkPackageName "svg-tree"
, mkPackageName "zip"
]
parseImports :: ByteString -> (Set PackageName, Set ModuleName)
parseImports =
fold . mapMaybe (parseLine . stripCR') . S8.lines
where
-- Remove any carriage pure character present at the end, to support
-- Windows-style line endings (CRLF)
stripCR' bs
| S8.null bs = bs
| S8.last bs == '\r' = S8.init bs
| otherwise = bs
stripPrefix x y
| x `S8.isPrefixOf` y = Just $ S8.drop (S8.length x) y
| otherwise = Nothing
parseLine bs0 = do
bs1 <- stripPrefix "import " bs0
let bs2 = S8.dropWhile (== ' ') bs1
bs3 = fromMaybe bs2 $ stripPrefix "qualified " bs2
case stripPrefix "\"" bs3 of
Just bs4 -> do
pn <- parsePackageNameThrowing $ S8.unpack $ S8.takeWhile (/= '"') bs4
Just (Set.singleton pn, Set.empty)
Nothing -> Just
( Set.empty
, Set.singleton
$ fromString
$ T.unpack
$ decodeUtf8With lenientDecode
$ S8.takeWhile (\c -> c /= ' ' && c /= '(') bs3
)