packages feed

stack-2.15.1: src/Stack/Build/Haddock.hs

{-# LANGUAGE NoImplicitPrelude   #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings   #-}

-- | Generate haddocks
module Stack.Build.Haddock
  ( generateDepsHaddockIndex
  , generateLocalHaddockIndex
  , generateSnapHaddockIndex
  , openHaddocksInBrowser
  , shouldHaddockDeps
  , shouldHaddockPackage
  , generateLocalHaddockForHackageArchives
  ) where

import qualified Codec.Archive.Tar as Tar
import qualified Codec.Compression.GZip as GZip
import qualified Data.Foldable as F
import qualified Data.HashSet as HS
import qualified Data.Map.Strict as Map
import qualified Data.Set as Set
import qualified Data.Text as T
import           Data.Time ( UTCTime )
import           Distribution.Text ( display )
import           Path
                   ( (</>), addExtension, fromAbsDir, fromAbsFile, fromRelDir
                   , parent, parseRelDir, parseRelFile
                   )
import           Path.Extra
                   ( parseCollapsedAbsFile, toFilePathNoTrailingSep
                   , tryGetModificationTime
                   )
import           Path.IO
                   ( copyDirRecur', doesFileExist, ensureDir, ignoringAbsence
                   , removeDirRecur
                   )
import qualified RIO.ByteString.Lazy as BL
import           RIO.List ( intercalate )
import           RIO.Process ( HasProcessContext, withWorkingDir )
import           Stack.Constants
                   ( docDirSuffix, htmlDirSuffix, relDirAll, relFileIndexHtml )
import           Stack.Constants.Config ( distDirFromDir )
import           Stack.Prelude hiding ( Display (..) )
import           Stack.Types.Build.Exception ( BuildException (..) )
import           Stack.Types.CompilerPaths
                   ( CompilerPaths (..), HasCompiler (..) )
import           Stack.Types.ConfigureOpts ( BaseConfigOpts (..) )
import           Stack.Types.BuildOpts ( BuildOpts (..), HaddockOpts (..) )
import           Stack.Types.BuildOptsCLI ( BuildOptsCLI (..) )
import           Stack.Types.DumpPackage ( DumpPackage (..) )
import           Stack.Types.EnvConfig ( HasEnvConfig (..) )
import           Stack.Types.GhcPkgId ( GhcPkgId )
import           Stack.Types.Package
                   ( InstallLocation (..), LocalPackage (..), Package (..) )
import qualified System.FilePath as FP
import           Web.Browser ( openBrowser )

openHaddocksInBrowser ::
     HasTerm env
  => BaseConfigOpts
  -> Map PackageName (PackageIdentifier, InstallLocation)
  -- ^ Available packages and their locations for the current project
  -> Set PackageName
  -- ^ Build targets as determined by 'Stack.Build.Source.loadSourceMap'
  -> RIO env ()
openHaddocksInBrowser bco pkgLocations buildTargets = do
  let cliTargets = bco.buildOptsCLI.targetsCLI
      getDocIndex = do
        let localDocs = haddockIndexFile (localDepsDocDir bco)
        localExists <- doesFileExist localDocs
        if localExists
          then pure localDocs
          else do
            let snapDocs = haddockIndexFile (snapDocDir bco)
            snapExists <- doesFileExist snapDocs
            if snapExists
              then pure snapDocs
              else throwIO HaddockIndexNotFound
  docFile <-
    case (cliTargets, map (`Map.lookup` pkgLocations) (Set.toList buildTargets)) of
      ([_], [Just (pkgId, iloc)]) -> do
        pkgRelDir <- (parseRelDir . packageIdentifierString) pkgId
        let docLocation =
              case iloc of
                Snap -> snapDocDir bco
                Local -> localDocDir bco
        let docFile = haddockIndexFile (docLocation </> pkgRelDir)
        exists <- doesFileExist docFile
        if exists
            then pure docFile
            else do
              prettyWarnL
                [ flow "Expected to find documentation at"
                , pretty docFile <> ","
                , flow "but that file is missing. Opening doc index instead."
                ]
              getDocIndex
      _ -> getDocIndex
  prettyInfo $ "Opening" <+> pretty docFile <+> "in the browser."
  _ <- liftIO $ openBrowser (toFilePath docFile)
  pure ()

-- | Determine whether we should haddock for a package.
shouldHaddockPackage ::
     BuildOpts
  -> Set PackageName
     -- ^ Packages that we want to generate haddocks for in any case (whether or
     -- not we are going to generate haddocks for dependencies)
  -> PackageName
  -> Bool
shouldHaddockPackage bopts wanted name =
  if Set.member name wanted
    then bopts.buildHaddocks
    else shouldHaddockDeps bopts

-- | Determine whether to build haddocks for dependencies.
shouldHaddockDeps :: BuildOpts -> Bool
shouldHaddockDeps bopts = fromMaybe bopts.buildHaddocks bopts.haddockDeps

-- | Generate Haddock index and contents for local packages.
generateLocalHaddockIndex ::
     (HasCompiler env, HasProcessContext env, HasTerm env)
  => BaseConfigOpts
  -> Map GhcPkgId DumpPackage  -- ^ Local package dump
  -> [LocalPackage]
  -> RIO env ()
generateLocalHaddockIndex bco localDumpPkgs locals = do
  let dumpPackages =
        mapMaybe
          ( \LocalPackage {package = Package {name, version}} ->
              F.find
                ( \dp -> dp.packageIdent ==
                         PackageIdentifier name version
                )
                localDumpPkgs
          )
          locals
  generateHaddockIndex
    "local packages"
    bco
    dumpPackages
    "."
    (localDocDir bco)

-- | Generate Haddock index and contents for local packages and their
-- dependencies.
generateDepsHaddockIndex ::
     (HasCompiler env, HasProcessContext env, HasTerm env)
  => BaseConfigOpts
  -> Map GhcPkgId DumpPackage  -- ^ Global dump information
  -> Map GhcPkgId DumpPackage  -- ^ Snapshot dump information
  -> Map GhcPkgId DumpPackage  -- ^ Local dump information
  -> [LocalPackage]
  -> RIO env ()
generateDepsHaddockIndex bco globalDumpPkgs snapshotDumpPkgs localDumpPkgs locals = do
  let deps = ( mapMaybe
                 (`lookupDumpPackage` allDumpPkgs)
                 . nubOrd
                 . findTransitiveDepends
                 . mapMaybe getGhcPkgId
             ) locals
      depDocDir = localDepsDocDir bco
  generateHaddockIndex
    "local packages and dependencies"
    bco
    deps
    ".."
    depDocDir
 where
  getGhcPkgId :: LocalPackage -> Maybe GhcPkgId
  getGhcPkgId LocalPackage {package = Package {name, version}} =
    let pkgId = PackageIdentifier name version
        mdpPkg = F.find (\dp -> dp.packageIdent == pkgId) localDumpPkgs
    in  fmap (.ghcPkgId) mdpPkg
  findTransitiveDepends :: [GhcPkgId] -> [GhcPkgId]
  findTransitiveDepends = (`go` HS.empty) . HS.fromList
   where
    go todo checked =
      case HS.toList todo of
        [] -> HS.toList checked
        (ghcPkgId:_) ->
          let deps = case lookupDumpPackage ghcPkgId allDumpPkgs of
                       Nothing -> HS.empty
                       Just pkgDP -> HS.fromList pkgDP.depends
              deps' = deps `HS.difference` checked
              todo' = HS.delete ghcPkgId (deps' `HS.union` todo)
              checked' = HS.insert ghcPkgId checked
          in  go todo' checked'
  allDumpPkgs = [localDumpPkgs, snapshotDumpPkgs, globalDumpPkgs]

-- | Generate Haddock index and contents for all snapshot packages.
generateSnapHaddockIndex ::
     (HasCompiler env, HasProcessContext env, HasTerm env)
  => BaseConfigOpts
  -> Map GhcPkgId DumpPackage  -- ^ Global package dump
  -> Map GhcPkgId DumpPackage  -- ^ Snapshot package dump
  -> RIO env ()
generateSnapHaddockIndex bco globalDumpPkgs snapshotDumpPkgs =
  generateHaddockIndex
    "snapshot packages"
    bco
    (Map.elems snapshotDumpPkgs ++ Map.elems globalDumpPkgs)
    "."
    (snapDocDir bco)

-- | Generate Haddock index and contents for specified packages.
generateHaddockIndex ::
     (HasCompiler env, HasProcessContext env, HasTerm env)
  => Text
  -> BaseConfigOpts
  -> [DumpPackage]
  -> FilePath
  -> Path Abs Dir
  -> RIO env ()
generateHaddockIndex descr bco dumpPackages docRelFP destDir = do
  ensureDir destDir
  interfaceOpts <-
    (liftIO . fmap nubOrd . mapMaybeM toInterfaceOpt) dumpPackages
  unless (null interfaceOpts) $ do
    let destIndexFile = haddockIndexFile destDir
    eindexModTime <- liftIO (tryGetModificationTime destIndexFile)
    let needUpdate =
          case eindexModTime of
            Left _ -> True
            Right indexModTime ->
              or [mt > indexModTime | (_, mt, _, _) <- interfaceOpts]
        prettyDescr = style Current (fromString $ T.unpack descr)
    if needUpdate
      then do
        prettyInfo $
             fillSep
               [ flow "Updating Haddock index for"
               , prettyDescr
               , "in:"
               ]
          <> line
          <> pretty destIndexFile
        liftIO (mapM_ copyPkgDocs interfaceOpts)
        haddockExeName <- view $ compilerPathsL . to (toFilePath . (.haddock))
        withWorkingDir (toFilePath destDir) $ readProcessNull
          haddockExeName
          ( map
              (("--optghc=-package-db=" ++ ) . toFilePathNoTrailingSep)
                 [bco.snapDB, bco.localDB]
              ++ bco.buildOpts.haddockOpts.additionalArgs
              ++ ["--gen-contents", "--gen-index"]
              ++ [x | (xs, _, _, _) <- interfaceOpts, x <- xs]
          )
      else
        prettyInfo $
             fillSep
               [ flow "Haddock index for"
               , prettyDescr
               , flow "already up to date at:"
               ]
          <> line
          <> pretty destIndexFile
 where
  toInterfaceOpt ::
       DumpPackage
    -> IO (Maybe ([String], UTCTime, Path Abs File, Path Abs File))
  toInterfaceOpt DumpPackage {haddockInterfaces, packageIdent, haddockHtml} =
    case haddockInterfaces of
      [] -> pure Nothing
      srcInterfaceFP:_ -> do
        srcInterfaceAbsFile <- parseCollapsedAbsFile srcInterfaceFP
        let (PackageIdentifier name _) = packageIdent
            destInterfaceRelFP =
              docRelFP FP.</>
              packageIdentifierString packageIdent FP.</>
              (packageNameString name FP.<.> "haddock")
            docPathRelFP =
              fmap ((docRelFP FP.</>) . FP.takeFileName) haddockHtml
            interfaces = intercalate "," $ mcons docPathRelFP [srcInterfaceFP]

        destInterfaceAbsFile <-
          parseCollapsedAbsFile (toFilePath destDir FP.</> destInterfaceRelFP)
        esrcInterfaceModTime <- tryGetModificationTime srcInterfaceAbsFile
        pure $
          case esrcInterfaceModTime of
            Left _ -> Nothing
            Right srcInterfaceModTime ->
              Just
                ( [ "-i", interfaces ]
                , srcInterfaceModTime
                , srcInterfaceAbsFile
                , destInterfaceAbsFile
                )
  copyPkgDocs :: (a, UTCTime, Path Abs File, Path Abs File) -> IO ()
  copyPkgDocs (_, srcInterfaceModTime, srcInterfaceAbsFile, destInterfaceAbsFile) = do
  -- Copy dependencies' haddocks to documentation directory.  This way,
  -- relative @../$pkg-$ver@ links work and it's easy to upload docs to a web
  -- server or otherwise view them in a non-local-filesystem context. We copy
  -- instead of symlink for two reasons: (1) symlinks aren't reliably supported
  -- on Windows, and (2) the filesystem containing dependencies' docs may not be
  -- available where viewing the docs (e.g. if building in a Docker container).
    edestInterfaceModTime <- tryGetModificationTime destInterfaceAbsFile
    case edestInterfaceModTime of
      Left _ -> doCopy
      Right destInterfaceModTime
        | destInterfaceModTime < srcInterfaceModTime -> doCopy
        | otherwise -> pure ()
   where
    doCopy = do
      ignoringAbsence (removeDirRecur destHtmlAbsDir)
      ensureDir destHtmlAbsDir
      onException
        (copyDirRecur' (parent srcInterfaceAbsFile) destHtmlAbsDir)
        (ignoringAbsence (removeDirRecur destHtmlAbsDir))
    destHtmlAbsDir = parent destInterfaceAbsFile

-- | Find first DumpPackage matching the GhcPkgId
lookupDumpPackage :: GhcPkgId
                  -> [Map GhcPkgId DumpPackage]
                  -> Maybe DumpPackage
lookupDumpPackage ghcPkgId dumpPkgs =
  listToMaybe $ mapMaybe (Map.lookup ghcPkgId) dumpPkgs

-- | Path of haddock index file.
haddockIndexFile :: Path Abs Dir -> Path Abs File
haddockIndexFile destDir = destDir </> relFileIndexHtml

-- | Path of local packages documentation directory.
localDocDir :: BaseConfigOpts -> Path Abs Dir
localDocDir bco = bco.localInstallRoot </> docDirSuffix

-- | Path of documentation directory for the dependencies of local packages
localDepsDocDir :: BaseConfigOpts -> Path Abs Dir
localDepsDocDir bco = localDocDir bco </> relDirAll

-- | Path of snapshot packages documentation directory.
snapDocDir :: BaseConfigOpts -> Path Abs Dir
snapDocDir bco = bco.snapInstallRoot </> docDirSuffix

generateLocalHaddockForHackageArchives ::
     (HasEnvConfig env, HasTerm env)
  => [LocalPackage]
  -> RIO env ()
generateLocalHaddockForHackageArchives =
  mapM_
    ( \lp ->
        let pkg = lp.package
            pkgId = PackageIdentifier pkg.name pkg.version
            pkgDir = parent lp.cabalFP
        in generateLocalHaddockForHackageArchive pkgDir pkgId
    )

-- | Generate an archive file containing local Haddock documentation for
-- Hackage, in a form accepted by Hackage.
generateLocalHaddockForHackageArchive ::
     (HasEnvConfig env, HasTerm env)
  => Path Abs Dir
     -- ^ The package directory.
  -> PackageIdentifier
     -- ^ The package name and version.
  -> RIO env ()
generateLocalHaddockForHackageArchive pkgDir pkgId = do
  distDir <- distDirFromDir pkgDir
  let pkgIdName = display pkgId
      name = pkgIdName <> "-docs"
      (nameRelDir, tarGzFileName) = fromMaybe
        (error "impossible")
        ( do relDir <- parseRelDir name
             nameRelFile <- parseRelFile name
             tarGz <- addExtension ".gz" =<< addExtension ".tar" nameRelFile
             pure (relDir, tarGz)
        )
      tarGzFile = distDir </> tarGzFileName
      docDir = distDir </> docDirSuffix </> htmlDirSuffix
  createTarGzFile tarGzFile docDir nameRelDir
  prettyInfo $
       fillSep
         [ flow "Archive of Haddock documentation for Hackage for"
         , style Current (fromString pkgIdName)
         , flow "created at:"
         ]
    <> line
    <> pretty tarGzFile

createTarGzFile ::
     Path Abs File
     -- ^ Full path to archive file
  -> Path Abs Dir
     -- ^ Base directory
  -> Path Rel Dir
     -- ^ Directory to archive, relative to base directory
  -> RIO env ()
createTarGzFile tar base dir = do
   entries <- liftIO $ Tar.pack base' [dir']
   BL.writeFile tar' $ GZip.compress $ Tar.write entries
 where
  base' = fromAbsDir base
  dir' = fromRelDir dir
  tar' = fromAbsFile tar