packages feed

hoogle-index-0.4.2: Main.hs

{-# LANGUAGE TupleSections #-}

import Control.Monad (void, forM, when, forever)
import Control.Monad.IO.Class
import Control.Concurrent
import Control.Applicative
import Data.Version
import Data.Char (isSpace)
import Data.Maybe (listToMaybe)
import Data.Ord (comparing)
import Data.List (sortBy,isPrefixOf)
import System.Directory
import System.Exit
import System.Process
import System.FilePath
import System.IO

import qualified Data.Set as S

import Data.Either (partitionEithers)
import Control.Error

import Options.Applicative

import qualified Data.ByteString.Char8 as BS
import System.IO.Temp

import Distribution.Verbosity (Verbosity, normal, verbose)
import Distribution.Simple.Compiler (Compiler (compilerId), compilerFlavor)
import Distribution.Simple.GHC
import Distribution.Simple.Program (defaultProgramConfiguration)
import Distribution.Simple.Compiler (PackageDB (..), PackageDBStack)
import Distribution.Simple.PackageIndex ( PackageIndex, lookupPackageName
                                        , reverseTopologicalOrder
                                        , searchByNameSubstring
                                        )
import Distribution.System (buildPlatform)
import Distribution.InstalledPackageInfo (InstalledPackageInfo_ (..), InstalledPackageInfo)
import Distribution.Package (PackageId, PackageName (..), PackageIdentifier (..))
import qualified Distribution.Simple.InstallDirs as IDirs

import qualified Paths_hoogle_index
import Data.Version

import Hoogle (defaultDatabaseLocation)

-- | Various configuration
data Config = Config { verbosity               :: Verbosity
                     , installTextBase         :: Bool
                     , useLocalDocs            :: Bool
                     , ignoreExistingTextBases :: Bool
                     , otherPackageDbs         :: [PackageDB]
                     , hoogleCall              :: Maybe HoogleCall
                     }

-- | Calling Hoogle
data HoogleCall = HoogleCall [String]

opts =
    Config <$> flag normal verbose
               ( short 'v' <> long "verbose"
              <> help "Enable verbose output"
               )
           <*> switch
               ( short 'i' <> long "install"
              <> help "Install generated textbases for future use"
               )
           <*> switch
               ( short 'l' <> long "local"
              <> help "Use local Haddock documentation when available"
               )
           <*> switch
               ( long "ignore-existing"
              <> help "Always regenerate textbases even if one already exists"
               )
           <*> many (option (SpecificPackageDB <$> str)
               ( short 'f' <> long "package-db"
              <> help "Add an addition package database (e.g. a Cabal sandbox)"
               ))
           <*> optional
               ( subparser
                 (command "hoogle" (info (HoogleCall <$> (some (argument str (metavar "ARGS..."))))
                                         (progDesc"Run Hoogle with proper sandboxing options" <> noIntersperse))
                 )
               )

-- | An unpacked Cabal project
newtype PackageTree = PkgTree FilePath
                    deriving (Show)

-- | Call a process
callProcessE :: Config -> FilePath -> [String] -> EitherT String IO ()
callProcessE ver = callProcessIn ver "."

-- | Call a process from the given directory
callProcessIn :: Config
              -> FilePath    -- ^ directory
              -> FilePath    -- ^ executable
              -> [String]    -- ^ arguments
              -> EitherT String IO ()
callProcessIn cfg dir exec args = do
    let ver = verbosity cfg
        stream
          | verbose < ver = CreatePipe
          | otherwise     = Inherit
    let cp = (proc exec args) { cwd = Just dir
                              , std_err = stream
                              , std_out = stream
                              }
    (_, hOut, hErr, ph) <- tryIO' $ createProcess cp

    let handleStream (Just h) = liftIO $ void $ forkIO $ forever $ hGetLine h
        handleStream Nothing  = return ()
    handleStream hOut
    handleStream hErr
    code <- liftIO $ waitForProcess ph
    case code of
      ExitSuccess -> return ()
      ExitFailure e -> left $ "Process "++exec++" failed with error "++show e

-- | Unpack a package
unpack :: Config -> PackageId -> EitherT String IO PackageTree
unpack cfg (PackageIdentifier (PackageName pkg) ver) = do
    tmpDir <- liftIO getTemporaryDirectory
    dir <- liftIO $ createTempDirectory tmpDir "hoogle-index.pkg"
    callProcessIn cfg dir "cabal" ["unpack", pkg++"=="++showVersion ver]
    return $ PkgTree $ dir </> pkg++"-"++showVersion ver

-- | Remove an unpacked tree
removeTree :: PackageTree -> IO ()
removeTree (PkgTree dir) =
    removeDirectoryRecursive $ takeDirectory dir

-- | A Haddock textbase
newtype TextBase = TextBase BS.ByteString

-- | Write a Haddock textbase to a file
writeTextBase :: TextBase -> FilePath -> EitherT String IO ()
writeTextBase (TextBase content) path = liftIO $ BS.writeFile path content

-- | A file containing a Haddock textbase
type TextBaseFile = FilePath

-- | Find a pre-installed textbase corresponding to a package (if one exists)
findTextBase :: InstalledPackageInfo -> IO (Maybe TextBaseFile)
findTextBase ipkg = do
    listToMaybe . catMaybes <$> mapM checkDir (haddockHTMLs ipkg)
  where
    PackageName name = pkgName $ sourcePackageId ipkg
    checkDir root = do
        let path = root </> name++".txt"
        putStrLn $ "Looking for "++path
        exists <- doesFileExist path
        if exists
          then return $ Just path
          else return Nothing

-- | Build a textbase from source
buildTextBase :: Config -> PackageName -> PackageTree -> EitherT String IO TextBase
buildTextBase cfg (PackageName pkg) (PkgTree dir) = do
    callProcessIn cfg dir "cabal" ["configure"]
    callProcessIn cfg dir "cabal" ["haddock", "--hoogle"]
    let path = dir </> "dist" </> "doc" </> "html" </> pkg </> (pkg++".txt")
    TextBase <$> liftIO (BS.readFile path)

placeTextBase :: Config -> InstalledPackageInfo -> TextBase -> EitherT String IO ()
placeTextBase cfg ipkg tb
  | Just docRoot <- listToMaybe $ haddockHTMLs ipkg = tryIO' $ do
      let TextBase content = tb
          tbPath = docRoot </> name++".txt"
      when (verbosity cfg > verbose)
          $ liftIO $ putStrLn $ "Installing textbase to "++tbPath
      createDirectoryIfMissing True docRoot
      BS.writeFile tbPath content
  | otherwise =
      liftIO $ putStrLn $ "Can't install textbase due to missing documentation directory"
  where
    pkg = sourcePackageId ipkg
    PackageName name = pkgName pkg

getTextBase :: Config -> InstalledPackageInfo -> EitherT String IO TextBase
getTextBase cfg ipkg = do
    existing <- if ignoreExistingTextBases cfg
                  then return Nothing
                  else liftIO $ findTextBase ipkg
    case existing of
      Just path -> TextBase <$> liftIO (BS.readFile path)
      Nothing -> do
        pkgTree <- unpack cfg pkg
        tb <- buildTextBase cfg (pkgName pkg) pkgTree
        liftIO $ removeTree pkgTree

        when (installTextBase cfg) $ do
            -- It's not the end of the world if this fails
            result <- liftIO $ runEitherT $ placeTextBase cfg ipkg tb
            case result of
              Left e  -> liftIO $ putStrLn $ name++": Failed to install textbase: "++e
              Right _ -> return ()
        return tb
  where
    pkg = sourcePackageId ipkg
    PackageName name = pkgName pkg

-- | A Hoogle database
newtype Database = DB FilePath
                 deriving (Show)

-- | Delete a database file
removeDB :: Database -> IO ()
removeDB (DB path) = removeFile path

-- | Convert a textbase to a Hoogle database
convert :: Config -> TextBaseFile
        -> Maybe FilePath             -- ^ documentation root
        -> [Database]
        -> EitherT String IO Database
convert cfg tbf docRoot merge = do
    let docRoot' = maybe [] (\d->["--haddock", "--doc="++d]) docRoot
    (tb,h) <- liftIO $ openTempFile "/tmp" "db.hoo"
    liftIO $ hClose h
    let args = ["convert", tbf, tb] ++ docRoot'
               ++ map (\(DB db)->"--merge="++db) merge

    callProcessE cfg "hoogle" args
    return $ DB tb

-- | Generate a Hoogle database for an installed package
indexPackage :: Config -> InstalledPackageInfo -> EitherT String IO Database
indexPackage cfg ipkg
  | pkgName pkg `S.member` downloadPackages = do
    tmpDir <- liftIO getTemporaryDirectory
    callProcessE cfg "hoogle" ["data", "--datadir="++tmpDir, name]
    return $ DB $ tmpDir </> name++".hoo"

  | pkgName pkg `S.member` ignorePackages =
    left $ "Can't build documentation for "++name

  | otherwise = do
    tb <- getTextBase cfg ipkg
    docRoot <- case haddockHTMLs ipkg of
                   docRoot:_ | useLocalDocs cfg -> return $ Just docRoot
                   []        | useLocalDocs cfg -> do
                     liftIO $ putStrLn $ "No local documentation for "++show pkg
                     return Nothing
                   _ -> return Nothing
    (tbf,h) <- liftIO $ openTempFile "/tmp" "textbase.txt"
    liftIO $ hClose h
    writeTextBase tb tbf
    db <- convert cfg tbf docRoot []
    tryIO' $ removeFile tbf
    return db
  where
    downloadPackages = S.fromList $ map PackageName
                       ["base"]
    ignorePackages = S.fromList $ map PackageName
                     [ "rts", "ghc-prim", "integer-gmp", "bin-package-db"
                     , "ghc", "haskell98", "haskell2010"]
    pkg = sourcePackageId ipkg
    PackageName name = pkgName pkg

-- | Combine Hoogle databases
combineDBs :: Config -> [Database] -> EitherT String IO Database
combineDBs cfg dbs = do
    tmpDir <- tryIO' $ getTemporaryDirectory
    (out, h) <- tryIO' $ openTempFile tmpDir "combined.hoo"
    tryIO' $ hClose h
    let args = ["combine", "--outfile="++out] ++ map (\(DB db)->db) dbs
    callProcessE cfg "hoogle" args
    return (DB out)

-- | Install a database in Hoogle's database directory
installDB :: Database -> SandboxPath -> EitherT String IO ()
installDB (DB db) sp = do
    dbDir <- liftIO $ getDatabaseLocation sp
    let destDir = dbDir </> "databases"
    tryIO' $ createDirectoryIfMissing True destDir
    let dest = destDir </> "default.hoo"
    liftIO $ copyFile db dest
    liftIO $ putStrLn $ "Installed Hoogle index to "++dest

main :: IO ()
main = do
    cfg <- execParser
        $ info (helper <*> opts)
               ( fullDesc
              <> progDesc "Generate Hoogle indexes for locally install packages"
              <> header "hoogle-index - Painless local Hoogle indexing"
              <> footer ("hoogle-index version "++showVersion Paths_hoogle_index.version)
               )
    sandboxPath <- getSandboxPath
    case hoogleCall cfg of
      Nothing -> do
        (compiler, _, progCfg) <- configure (verbosity cfg)
                                  Nothing Nothing
                                  defaultProgramConfiguration
        let pkgDbs = getPackageDBs cfg sandboxPath
        pkgIdx <- getInstalledPackages (verbosity cfg) pkgDbs progCfg
        let pkgs = reverseTopologicalOrder pkgIdx
            maybeIndex :: InstalledPackageInfo -> IO (Either (PackageId, String) Database)
            maybeIndex pkg = do
                putStrLn ""
                result <- runEitherT $ indexPackage cfg pkg
                case result of
                  Left e  -> do
                    let pkgId = sourcePackageId pkg
                        PackageName name = pkgName pkgId
                    putStrLn $ "Error while indexing "++name++": "++e
                    return $ Left (pkgId, e)
                  Right r -> return $ Right r
        (failed, idxs) <- fmap partitionEithers $ mapM maybeIndex pkgs

        when (not $ null failed) $ do
          putStrLn "Failed to build the following indexes:"
          let failedMsg (pkgId, reason) = "  "++show (pkgName pkgId)++"\t"++reason
          putStrLn $ unlines $ map failedMsg failed

        res <- runEitherT $ do
            combined <- fmapLT ("Error while combining databases: "++)
                        $ combineDBs cfg idxs
            installDB combined sandboxPath
        either putStrLn return res

        mapM_ removeDB idxs
      Just (HoogleCall args) -> do
        res <- runEitherT $
                callProcessE cfg "hoogle" $ getHoogleArgs sandboxPath args
        either putStrLn return res

tryIO' :: IO a -> EitherT String IO a
tryIO' = fmapLT show . tryIO


------------------------- Sandbox management --------------------------

-- | The sandbox package database location
type SandboxPath = Maybe FilePath

-- | The location of the Hoogle database
getDatabaseLocation :: SandboxPath -> IO FilePath
getDatabaseLocation Nothing = defaultDatabaseLocation
getDatabaseLocation (Just path) = return $ (takeDirectory path) </> "hoogle"

-- | Get the path to the sandbox database if any
getSandboxPath :: IO SandboxPath
getSandboxPath = do
  dir <- getCurrentDirectory
  let f = dir </> "cabal.sandbox.config"
  ex <- doesFileExist f
  if ex
    then
      (listToMaybe .
       map (dropWhile isSpace . tail . dropWhile (/= ':')) .
       filter (isPrefixOf "package-db") .
       lines) <$> readFile f
    else return Nothing

-- | Get the package database stack 
getPackageDBs :: Config -> SandboxPath -> PackageDBStack
getPackageDBs cfg Nothing   = [GlobalPackageDB, UserPackageDB] ++ otherPackageDbs cfg
getPackageDBs cfg (Just sp) = [GlobalPackageDB, SpecificPackageDB sp] ++ otherPackageDbs cfg

-- | Get the Hoogle arguments, using the sandbox databases if any
getHoogleArgs :: SandboxPath -> [String] -> [String]
getHoogleArgs Nothing args = args
getHoogleArgs (Just path) args = args ++ ["-d", (takeDirectory path) </> "hoogle" </> "databases"]