sandman-0.1.0.0: Main.hs
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
module Main (main) where
import Control.Applicative
import Control.Monad
import Data.List (stripPrefix)
import Data.Maybe (fromMaybe, isJust, listToMaybe)
import Data.Monoid
import Data.Set (Set)
import Data.Text (Text)
import System.Directory (canonicalizePath, copyFile,
createDirectoryIfMissing, doesDirectoryExist,
doesFileExist, findExecutable, getHomeDirectory,
removeFile)
import System.Exit (ExitCode (..))
import System.FilePath (splitDirectories, takeDirectory, takeFileName,
(</>))
import qualified Data.Map.Strict as Map
import qualified Data.Set as Set
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import qualified Distribution.InstalledPackageInfo as PInfo
import qualified Distribution.Text as Cabal
import qualified Options.Applicative as O
import qualified System.Process as Proc
import Sandman.InstalledPackage
import Sandman.PackageDb
import Sandman.Util
------------------------------------------------------------------------------
-- | Main context for the program.
--
-- Currently this just consists of the root directory where all sandman files
-- will be stored.
newtype Sandman = Sandman { sandmanDirectory :: FilePath }
deriving (Show, Ord, Eq)
-- | Build the context with default settings.
defaultSandman :: IO Sandman
defaultSandman = do
home <- getHomeDirectory
return $! Sandman (home </> ".sandman")
-- | Path to the directory which will hold the sandboxes.
sandboxesDirectory :: Sandman -> FilePath
sandboxesDirectory Sandman{sandmanDirectory} =
sandmanDirectory </> "sandboxes"
-- | Get all managed sandboxes.
getSandboxes :: Sandman -> IO [Sandbox]
getSandboxes sandman = do
exists <- doesDirectoryExist sandboxesDir
if exists
then map Sandbox <$> listDirectory sandboxesDir
else return []
where
sandboxesDir = sandboxesDirectory sandman
-- | Get the sandbox with the given name.
getSandbox :: Sandman -> Text -> IO (Maybe Sandbox)
getSandbox sandman name = do
exists <- doesDirectoryExist sandboxDir
if exists
then return . Just . Sandbox $ sandboxDir
else return Nothing
where
sandboxDir = sandboxesDirectory sandman </> T.unpack name
------------------------------------------------------------------------------
-- | Represents a cabal sandbox.
newtype Sandbox = Sandbox {
sandboxRoot :: FilePath
-- ^ Path to the sandbox root.
--
-- Note: This is /not/ the project root. It just happens to be that the
-- project root and sandbox root is the same for managed sandboxes.
} deriving (Show, Ord, Eq)
-- | Name of the sandbox.
sandboxName :: Sandbox -> Text
sandboxName = T.pack . takeFileName . sandboxRoot
-- | Create a new managed sandbox with the given name.
createSandbox :: Sandman -> Text -> IO Sandbox
createSandbox sandman name = do
whenM (doesDirectoryExist sandboxDir) $
die $ "Sandbox " <> name <> " already exists."
createDirectoryIfMissing True sandboxDir
let proc = (Proc.proc "cabal" ["sandbox", "init", "--sandbox=."]) {
Proc.cwd = Just sandboxDir
}
(_, _, _, procHandle) <- Proc.createProcess proc
exitResult <- Proc.waitForProcess procHandle
case exitResult of
ExitSuccess -> return $! Sandbox sandboxDir
ExitFailure _ -> die $ "Failed to create sandbox " <> name
where
sandboxDir = sandboxesDirectory sandman </> T.unpack name
-- | Install the specified packages into the sandbox.
installPackages :: Sandbox -> [Text] -> IO ()
installPackages sandbox@Sandbox{sandboxRoot} packages = do
(_, _, _, procHandle) <- Proc.createProcess proc
exitResult <- Proc.waitForProcess procHandle
case exitResult of
ExitSuccess -> return ()
ExitFailure _ -> die $ "Failed to install packages to " <> name
where
name = sandboxName sandbox
proc = (Proc.proc "cabal" $ ["install"] <> map T.unpack packages) {
Proc.cwd = Just sandboxRoot
}
------------------------------------------------------------------------------
-- TODO probably need another data type for Projects.
getPackageGhcPath :: FilePath -> IO (Maybe FilePath)
getPackageGhcPath packageRoot = do
hasConfig <- doesFileExist cabalConfigPath
if not hasConfig
then return Nothing
else TIO.readFile cabalConfigPath
<&> map readField . T.lines
<&> filter (("with-compiler" ==) . fst)
<&> fmap (T.unpack . snd) . listToMaybe
where
readField line = (T.strip k, T.strip $ T.drop 1 v)
where (k, v) = T.breakOn ":" $ T.strip line
cabalConfigPath = packageRoot </> "cabal.config"
setPackageGhcPath :: FilePath -> FilePath -> IO ()
setPackageGhcPath packageRoot ghc = do
hasConfig <- doesFileExist cabalConfigPath
if not hasConfig
then TIO.writeFile cabalConfigPath entry
else TIO.readFile cabalConfigPath
<&> T.unlines . loop [] . T.lines
>>= TIO.writeFile cabalConfigPath
where
entry = "with-compiler: " <> T.pack ghc
cabalConfigPath = packageRoot </> "cabal.config"
loop outLines [] = reverse outLines
loop outLines (x:xs)
| "with-compiler" `T.isPrefixOf` T.strip x
= loop (entry:outLines) xs
| otherwise = loop (x:outLines) xs
-- | Get the PackageDb for the given package.
--
-- The package root is the directory containing the @cabal.sandbox.config@.
determinePackageDb :: FilePath -> IO (Either String PackageDb)
determinePackageDb packageRoot = do
-- TODO check if sandboxConfig exists
matches <- TIO.readFile sandboxConfig
<&> filter ("package-db:" `T.isPrefixOf`) . T.lines
case listToMaybe matches of
Nothing -> return . Left $
"Could not determine package DB for " ++ packageRoot
Just line ->
let right = T.drop 1 $ T.dropWhile (/= ':') line
value = T.dropWhile (\c -> c == ' ' || c == '\t') right
root = T.unpack value
in getPackageDb root
where
sandboxConfig = packageRoot </> "cabal.sandbox.config"
-- | Get the base package DB given the path to GHC.
--
-- Uses the default GHC if given @Nothing@.
getBasePackageDb :: Maybe FilePath -> IO (Either String PackageDb)
getBasePackageDb ghcPath' =
Proc.readProcess ghcPath ["--print-global-package-db"] ""
<&> T.unpack . T.strip . T.pack
>>= getPackageDb
where
ghcPath = fromMaybe "ghc" ghcPath'
-- | Get the number of packages installed in the given package DB.
installedPackageCount :: PackageDb -> Int
installedPackageCount = length . packageDbInstalledPackages
------------------------------------------------------------------------------
list :: IO ()
list = do
sandman <- defaultSandman -- FIXME
sandboxes <- getSandboxes sandman
when (null sandboxes) $
putStrLn "No sandboxes created."
forM_ sandboxes $ \sandbox -> do
let name = sandboxName sandbox
packageDb' <- determinePackageDb (sandboxRoot sandbox)
case packageDb' of
Left err -> do
warn (T.pack err)
TIO.putStrLn $ name <> "(ERROR: could not read package DB)"
Right packageDb -> do
let packageCount = installedPackageCount packageDb
TIO.putStrLn $ T.unwords
[name, "(" <> tshow packageCount, "packages)"]
------------------------------------------------------------------------------
new :: Maybe FilePath -> Text -> IO ()
new ghcPath' name = do
ghcPath <- maybe (return Nothing)
(fmap Just . resolveExecutable)
ghcPath'
sandman <- defaultSandman -- FIXME
Sandbox{sandboxRoot} <- createSandbox sandman name
maybe (return ())
(setPackageGhcPath sandboxRoot)
ghcPath
TIO.putStrLn $ "Created sandbox " <> name <> "."
where
resolveExecutable path = do
exists <- doesFileExist path
if exists
then canonicalizePath path
else
findExecutable path >>=
maybe (die $ "Could not find GHC at " <> T.pack path) return
------------------------------------------------------------------------------
destroy :: Text -> IO ()
destroy name = do
sandman <- defaultSandman
Sandbox{sandboxRoot} <- getSandbox sandman name
>>= maybe (die $ "Sandbox " <> name <> " does not exist.") return
removeTree sandboxRoot
TIO.putStrLn $ "Removed sandbox " <> name <> "."
------------------------------------------------------------------------------
install :: Text -> [Text] -> IO ()
install name packages = do
-- TODO parse package IDs
sandman <- defaultSandman
sandbox <- getSandbox sandman name
>>= maybe (die $ "Sandbox " <> name <> " does not exist.") return
installPackages sandbox packages
------------------------------------------------------------------------------
listPackages :: Text -> IO ()
listPackages name = do
sandman <- defaultSandman
-- TODO get rid of all this duplication
sandbox <- getSandbox sandman name
>>= maybe (die $ "Sandbox " <> name <> " does not exist.") return
packageDb <- determinePackageDb (sandboxRoot sandbox)
>>= either fail return
let packageIds = packageDbInstalledPackages packageDb
<&> installedPackageId
when (null packageIds) $
dieHappy $ name <> " does not contain any packages."
forM_ packageIds TIO.putStrLn
------------------------------------------------------------------------------
mix :: [Text] -> Bool -> Text -> IO ()
mix packageNames includeExecutables name = do
currentPackageDb <- determinePackageDb "." >>= either fail return
sandman <- defaultSandman
sandbox <- getSandbox sandman name
>>= maybe (die $ "Sandbox " <> name <> " does not exist.") return
sandboxPackageDb <- determinePackageDb (sandboxRoot sandbox)
>>= either fail return
let sandboxPackageNames = Set.fromList . map installedPackageName $
packageDbInstalledPackages sandboxPackageDb
-- Make sure that all requested packages are installed.
forM_ packageNames $ \requestedPackage ->
unless (requestedPackage `Set.member` sandboxPackageNames) $
die $ requestedPackage <> " is not installed in " <> name
basePackageIds <- getPackageGhcPath (sandboxRoot sandbox)
>>= getBasePackageDb >>= either fail return
<&> map installedPackageId . packageDbInstalledPackages
let -- Returns True if another package with the same name has already been
-- installed to the target sandbox
isAlreadyInstalled =
(`Set.member` alreadyInstalled) . installedPackageName
where
alreadyInstalled = Set.fromList . map installedPackageName $
packageDbInstalledPackages currentPackageDb
-- Reverse mapping from Cabal's InstalledPackageId to InstalledPackage
-- for all packages in the managed sandbox
installedPackageIdIndex = Map.fromList $ do
installedPackage <- packageDbInstalledPackages sandboxPackageDb
let pinfo = installedPackageInfo installedPackage
return (PInfo.installedPackageId pinfo, installedPackage)
-- Reverse mapping from package names to InstalledPackages for all
-- packages in the managed sandbox
installedPackageNameIndex = Map.fromList $ do
installedPackage <- packageDbInstalledPackages sandboxPackageDb
return (installedPackageName installedPackage, installedPackage)
isBase pkgId' = any (`T.isPrefixOf` pkgId) basePackageIds
where pkgId = T.pack $ Cabal.display pkgId'
-- Get the InstalledPackage objects for the direct dependencies of the
-- given InstaledPackage
getDirectDependencies pkg = do
dep <- PInfo.depends (installedPackageInfo pkg)
if isBase dep then mzero else
case dep `Map.lookup` installedPackageIdIndex of
Nothing -> error $ unwords [
"Installed package", T.unpack (installedPackageName pkg)
, "depends on", Cabal.display dep
, "which is not installed in sandbox", T.unpack name
]
Just depPkg -> return depPkg
-- Get the InstalledPackage objects for all dependencies of the given
-- InstaledPackage.
--
-- This includes dependencies of dependencies and so on.
getDependencies _pkg = loop Set.empty [] [_pkg]
where
loop _ result [] = result
loop visited result (pkg:pkgs)
| pname `Set.member` visited = loop visited result pkgs
| otherwise =
loop (pname `Set.insert` visited)
(pkg:result)
(pkgs ++ getDirectDependencies pkg)
where
pname = installedPackageName pkg
-- Names of requested packages and their dependencies
requestedPackages :: Set Text
requestedPackages = Set.fromList $ do
pkgName <- packageNames
case pkgName `Map.lookup` installedPackageNameIndex of
Nothing -> error $ unwords [
"Requested package", T.unpack pkgName
, "is not installed in sandbox", T.unpack name
]
Just installedPkg -> do
depPkg <- getDependencies installedPkg
[pkgName, installedPackageName depPkg]
-- Whether a package was requested for installation.
--
-- Always returns True if --only was skipped. If --only was given,
-- returns true only for requested packages and their dependencies.
isRequested = if null packageNames then const True else
(`Set.member` requestedPackages) . installedPackageName
-- Returns True if the package should be installed
shouldInstall pkg = isRequested pkg && not (isAlreadyInstalled pkg)
-- List of packages that will be installed
packagesToInstall = filter shouldInstall $
packageDbInstalledPackages sandboxPackageDb
newPackageCount = length packagesToInstall
when (newPackageCount == 0) $
dieHappy "No packages to mix in."
putStrLn $ unwords [
"Mixing", show newPackageCount
, "new packages into package DB at"
, packageDbRoot currentPackageDb
]
let currentPackageDbRoot = packageDbRoot currentPackageDb
forM_ packagesToInstall $ \installedPackage -> do
let currentPath = installedPackageInfoPath installedPackage
newPath = currentPackageDbRoot </> takeFileName currentPath
copyFile currentPath newPath
executables <- listDirectory (sandboxRoot sandbox </> "bin")
when (includeExecutables && not (null executables)) $ do
let newBinDir = takeDirectory currentPackageDbRoot </> "bin"
createDirectoryIfMissing True newBinDir
forM_ executables $ \exec -> do
let newPath = newBinDir </> takeFileName exec
alreadyExists <- doesFileExist newPath
unless alreadyExists $ copyFile exec newPath
ghcPath <- getPackageGhcPath (sandboxRoot sandbox)
case ghcPath of
Nothing -> return ()
Just path -> do
putStrLn $ "Setting GHC version for project to " ++ path
setPackageGhcPath "." path
putStrLn "Rebuilding package cache."
Proc.callProcess "cabal" ["sandbox", "hc-pkg", "recache"]
------------------------------------------------------------------------------
clean :: IO ()
clean = do
currentPackageDb <- determinePackageDb "." >>= either fail return
sandman <- defaultSandman
putStrLn "Removing all mixed sandboxes."
let packages = filterPackages sandman $
packageDbInstalledPackages currentPackageDb
when (null packages) $
dieHappy "No packages to remove."
forM_ packages $ removeFile . installedPackageInfoPath
putStrLn $ "Removed " <> show (length packages) <> " packages."
putStrLn "Rebuilding package cache."
Proc.callProcess "cabal" ["sandbox", "hc-pkg", "recache"]
where
-- FIXME this will probably cause all kinds of trouble if one managed
-- sandbox is mixed into another. That should be disallowed or this should
-- be smarter.
filterPackages :: Sandman -> [InstalledPackage] -> [InstalledPackage]
filterPackages Sandman{sandmanDirectory} = filter isMixedIn
where
isSandmanPath p = isJust $
stripPrefix (splitDirectories sandmanDirectory)
(splitDirectories p)
isMixedIn installedPackage = any isSandmanPath $
concatMap ($ packageInfo) [
PInfo.importDirs
, PInfo.libraryDirs
, PInfo.haddockInterfaces
]
where
packageInfo = installedPackageInfo installedPackage
------------------------------------------------------------------------------
argParser :: O.Parser (IO ())
argParser = O.subparser $ mconcat [
-- TODO come up with a better name for managed sandboxes than "sandman
-- sandboxes"
command "list" "List sandman sandboxes or the packages in them" $
maybe list listPackages <$> listNameArgument
, command "new" "Create a new sandman sandbox" $
new <$> newOptions
<*> nameArgument
, command "destroy" "Delete a sandman sandbox" $
destroy <$> nameArgument
, command "install" "Install a new package" $
install <$> nameArgument <*> packagesArgument
, command "mix" "Mix a sandman sandbox into the current project" $
mix <$> many (T.pack <$> packageNameOption)
<*> includeExecutablesOption
<*> nameArgument
, command "clean" "Remove all mixed sandboxes from the current project" $
pure clean
]
where
includeExecutablesOption = O.switch $
O.long "executables" <> O.short 'x' <>
O.help "Mix executables from the managed sandbox into the project."
packageNameOption = O.strOption $
O.long "only" <> O.short 'o' <> O.metavar "PACKAGE" <>
O.help (unwords [
"If added, only the specified packages (and their dependencies)"
, "will be mixed in. This option may be specified multiple times."
])
newOptions = O.optional . O.strOption $
O.long "with-ghc" <> O.metavar "GHC" <>
O.help (unwords [
"Use a different version of GHC in this sandbox."
, "When this sandbox is mixed into package sandboxes, their"
, "cabal.config will be updated to use this version of GHC."
])
listNameArgument = O.optional . textArgument $ O.metavar "name" <>
O.help (unwords [
"If given, list packages installed in the specified sandbox,"
, "otherwise list all sandman sandboxes"
])
packagesArgument = O.some . textArgument $
O.metavar "PACKAGES" <> O.help "Packages to install"
nameArgument = textArgument $
O.metavar "NAME" <> O.help "Name of the sandman sandbox"
textArgument = fmap T.pack . O.strArgument
command name desc p =
O.command name (O.info (O.helper <*> p) (O.progDesc desc))
main :: IO ()
main = join $ O.execParser opts
where
opts = O.info (O.helper <*> argParser) O.fullDesc