packages feed

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

{-# LANGUAGE NoImplicitPrelude   #-}
{-# LANGUAGE LambdaCase          #-}
{-# 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           Distribution.Text ( display )
import           Path
                   ( (</>), addExtension, dirname, fileExtension, filename
                   , fromAbsDir, fromAbsFile, fromRelDir, parent, parseRelDir
                   , parseRelFile
                   )
import           Path.Extra
                   ( parseCollapsedAbsFile, toFilePathNoTrailingSep
                   , tryGetModificationTime
                   )
import           Path.IO
                   ( copyDirRecur, copyDirRecur', doesDirExist, doesFileExist
                   , ensureDir, ignoringAbsence, listDir, removeDirRecur
                   )
import qualified RIO.ByteString.Lazy as BL
import           RIO.List ( intercalate, intersperse )
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 (..), BuildSubset (BSOnlyDependencies, BSOnlySnapshot) )
import           Stack.Types.DumpPackage ( DumpPackage (..) )
import           Stack.Types.EnvConfig ( EnvConfig (..), HasEnvConfig (..) )
import           Stack.Types.GhcPkgId ( GhcPkgId )
import           Stack.Types.InterfaceOpt ( InterfaceOpt (..) )
import           Stack.Types.Package
                   ( InstallLocation (..), LocalPackage (..), Package (..) )
import qualified System.FilePath as FP
import           Web.Browser ( openBrowser )
import RIO.FilePath (dropTrailingPathSeparator)

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."
  void $ liftIO $ openBrowser (toFilePath docFile)

-- | 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 project 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
    "project packages"
    bco
    dumpPackages
    "."
    (localDocDir bco)

-- | Generate Haddock index and contents for project 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
    "project 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 <- map (.srcInterfaceFileModTime) 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 <- map (.readInterfaceArgs) 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 InterfaceOpt)
  toInterfaceOpt dp =
    case dp.haddockInterfaces of
      [] -> pure Nothing
      srcInterfaceFP:_ -> do
        srcInterfaceFile <- parseCollapsedAbsFile srcInterfaceFP
        let (PackageIdentifier name _) = dp.packageIdent
            srcInterfaceDir = parent srcInterfaceFile
        compInterfaceDirsAndFiles <- do
          -- It is possible that the *.haddock file specified by the
          -- haddock-interfaces key for an installed package may not exist. For
          -- example, with GHC 9.6.6 on Windows, there is no
          --
          -- ${pkgroot}/../doc/html/libraries/rts-1.0.2\rts.haddock
          (srcInterfaceSubDirs, _) <- doesDirExist srcInterfaceDir >>= \case
            True -> listDir srcInterfaceDir
            False -> pure ([], [])
          -- This assumes that Cabal (the library) `haddock --executables` for
          -- component my-component of package my-package puts one *.haddock
          -- file and associated files in directory:
          --
          -- my-package/my-component
          --
          -- Not all directories in directory my-package relate to components.
          -- For example, my-package/src relates to the files for the
          -- colourised code of the main library of package my-package.
          let isCompInterfaceDir dir = do
                (_, files) <- listDir dir
                pure $ (dir, ) <$> F.find isInterface files
               where
                isInterface file = fileExtension file == Just ".haddock"
          mapMaybeM isCompInterfaceDir srcInterfaceSubDirs
        -- Lift a copy of the component's Haddock directory up to the same level
        -- as the main library's Haddock directory. For compontent my-component
        -- of package my-package we name the directory my-package_my-component.
        let liftcompInterfaceDir dir file = do
              let parentDir = parent dir
                  parentName = dirname parentDir
                  compName = dirname dir
              uniqueName <- do
                let parentName' =
                      dropTrailingPathSeparator $ toFilePath parentName
                    compName' =
                      dropTrailingPathSeparator $ toFilePath compName
                parseRelDir $ parentName' <> "_" <> compName'
              let destCompDir = parent parentDir </> uniqueName
                  destCompFile = destCompDir </> filename file
              ignoringAbsence (removeDirRecur destCompDir)
              ensureDir destCompDir
              onException
                (copyDirRecur dir destCompDir)
                (ignoringAbsence (removeDirRecur destCompDir))
              pure (destCompFile, uniqueName)
            destInterfaceRelFP =
              docRelFP FP.</>
              packageIdentifierString dp.packageIdent FP.</>
              (packageNameString name FP.<.> "haddock")
            docPathRelFP =
              fmap ((docRelFP FP.</>) . FP.takeFileName) dp.haddockHtml
            mkInterface :: Maybe FilePath -> FilePath -> String
            mkInterface mDocPath file =
              intercalate "," $ mcons mDocPath [file]
            compInterface :: (Path Abs Dir, Path Abs File) -> IO String
            compInterface (dir, file) = do
              (file', uniqueName) <- liftcompInterfaceDir dir file
              let compDir = dropTrailingPathSeparator $ toFilePath uniqueName
                  docDir = docRelFP FP.</> compDir
              pure $ mkInterface (Just docDir) (toFilePath file')
            interfaces = mkInterface docPathRelFP srcInterfaceFP
        compInterfaces <- forM compInterfaceDirsAndFiles compInterface
        let readInterfaceArgs =
              "-i" : intersperse "-i" (interfaces : compInterfaces)
        destInterfaceFile <-
          parseCollapsedAbsFile (toFilePath destDir FP.</> destInterfaceRelFP)
        eSrcInterfaceFileModTime <- tryGetModificationTime srcInterfaceFile
        pure $
          case eSrcInterfaceFileModTime of
            Left _ -> Nothing
            Right srcInterfaceFileModTime ->
              Just InterfaceOpt
                { readInterfaceArgs
                , srcInterfaceFileModTime
                , srcInterfaceFile
                , destInterfaceFile
                }
  copyPkgDocs :: InterfaceOpt -> IO ()
  copyPkgDocs opts = 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 opts.destInterfaceFile
    case edestInterfaceModTime of
      Left _ -> doCopy
      Right destInterfaceModTime
        | destInterfaceModTime < opts.srcInterfaceFileModTime -> doCopy
        | otherwise -> pure ()
   where
    doCopy = do
      ignoringAbsence (removeDirRecur destHtmlAbsDir)
      ensureDir destHtmlAbsDir
      onException
        (copyDirRecur' (parent opts.srcInterfaceFile) destHtmlAbsDir)
        (ignoringAbsence (removeDirRecur destHtmlAbsDir))
    destHtmlAbsDir = parent opts.destInterfaceFile

-- | 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 project packages documentation directory.
localDocDir :: BaseConfigOpts -> Path Abs Dir
localDocDir bco = bco.localInstallRoot </> docDirSuffix

-- | Path of documentation directory for the dependencies of project 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 lps = do
  buildSubset <- view $ envConfigL . to (.buildOptsCLI.buildSubset)
  let localsExcluded =
        buildSubset == BSOnlyDependencies || buildSubset == BSOnlySnapshot
  unless localsExcluded $
    forM_ lps $ \lp ->
      let pkg = lp.package
          pkgId = PackageIdentifier pkg.name pkg.version
          pkgDir = parent lp.cabalFP
      in  when lp.wanted $
            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
  tarGzFileCreated <- createTarGzFile tarGzFile docDir nameRelDir
  if tarGzFileCreated
    then
      prettyInfo $
           fillSep
             [ flow "Archive of Haddock documentation for Hackage for"
             , style Current (fromString pkgIdName)
             , flow "created at:"
             ]
        <> line
        <> pretty tarGzFile
    else
      prettyWarnL
        [ flow "No Haddock documentation for Hackage available for"
        , style Error (fromString pkgIdName) <> "."
        ]

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 Bool
createTarGzFile tar base dir = do
  dirExists <- doesDirExist $ base </> dir
  if dirExists
    then do
      entries <- liftIO $ Tar.pack base' [dir']
      if null entries
        then pure False
        else do
          ensureDir $ parent tar
          BL.writeFile tar' $ GZip.compress $ Tar.write entries
          pure True
    else pure False
 where
  base' = fromAbsDir base
  dir' = fromRelDir dir
  tar' = fromAbsFile tar