packages feed

stan-0.2.1.0: src/Stan/Cabal.hs

{- |
Copyright: (c) 2020 Kowainik
SPDX-License-Identifier: MPL-2.0
Maintainer: Kowainik <xrom.xkov@gmail.com>

Functions to work with cabal files and cabal extension maps.
-}

{-# LANGUAGE CPP #-}
#ifdef MIN_VERSION_directory_ospath_streaming
{-# LANGUAGE QuasiQuotes #-}
#endif

module Stan.Cabal
    ( createCabalExtensionsMap
    , usedCabalFiles

    , mergeParsedExtensions
    ) where

import Relude.Extra.Tuple (toSnd)

import Colourista (errorMessage, infoMessage, warningMessage)
import Control.Exception (catch)
import Extensions (CabalException, ExtensionsError (..), ExtensionsResult, ParsedExtensions (..),
                   mergeAnyExtensions, parseCabalFileExtensions)
import System.Directory (doesFileExist, makeRelativeToCurrentDirectory)

import Stan.Hie.Compat (HieFile (..))

import qualified Data.Map.Strict as Map
#ifndef MIN_VERSION_directory_ospath_streaming
import System.IO.Unsafe (unsafeInterleaveIO)
import qualified System.Directory as FilePath
import qualified System.FilePath as FilePath
#else
import qualified System.OsPath as OsPath
import qualified System.Directory.OsPath as OsPath
import qualified System.Directory.OsPath.Streaming as OPS
import qualified System.Directory.OsPath.Types as OPS
import qualified Data.Set as S
#endif


{- | Gets the list of @.cabal@ file paths that were used in the project.
-}
usedCabalFiles :: [FilePath] -> IO [FilePath]
usedCabalFiles fs = do
    cabals <- case fs of
        []    -> findCabalFiles
        files -> pure files
    mapM makeRelativeToCurrentDirectory cabals

{- | From a given path to cabal files and 'HieFile's create the map from modules
(that are in .cabal file) to the resulting parsed extensions for each.
-}
createCabalExtensionsMap
    :: Bool  -- ^ Do print into terminal?
    -> [FilePath]  -- ^ @.cabal@ files
    -> [HieFile]
    -> IO (Map FilePath (Either ExtensionsError ParsedExtensions))
createCabalExtensionsMap isLoud cabalPath hies = case cabalPath of
    -- if cabal files are not specified via CLI option
    -- try to find cabal files in current directory
    [] -> findCabalFiles >>= \case
        -- if cabal file is not found, pass the empty map instead
        [] -> do
            when isLoud $ do
                warningMessage ".cabal file not found in the current directory."
                infoMessage " 💡 Try using --cabal-file-path option to specify the path to the .cabal file.\n"
            pure mempty
        -- else concat map for each @.cabal@ file.
        cabals -> mconcat <$> mapM getExtensionsWithCabal cabals
    -- if cabal file specified via CLI option
    cabals -> fmap mconcat $ forM (ordNub cabals) $ \cabal ->
        ifM (doesFileExist cabal)
        {- then -} (getExtensionsWithCabal cabal)
        {- else -} (errorMessage (".cabal file does not exist: " <> toText cabal) >> exitFailure)
  where
    getExtensionsWithCabal
        :: FilePath
        -> IO (Map FilePath (Either ExtensionsError ParsedExtensions))
    getExtensionsWithCabal cabal = do
        when isLoud $ infoMessage $ "Using the following .cabal file: " <> toText cabal <> "\n"
        (Right <<$>> parseCabalFileExtensions cabal)
            `catch` handleCabalErr
      where
        handleCabalErr
            :: CabalException
            -> IO (Map FilePath (Either ExtensionsError ParsedExtensions))
        handleCabalErr err = do
            when isLoud $ errorMessage "Error when parsing cabal file. Stan will continue without information from .cabal file"
            pure $ Map.fromList $
                map (toSnd (const $ Left $ CabalError err) . hie_hs_file) hies

{- | Recursively find all @.cabal@ files in the current directory and its
subdirectories. It returns maximum 1 @.cabal@ file from each directory.
-}
findCabalFiles :: IO [FilePath]
findCabalFiles =
#ifndef MIN_VERSION_directory_ospath_streaming
    findCabalFilesFilePath
#else
    findCabalFilesStreaming
#endif

#ifndef MIN_VERSION_directory_ospath_streaming
findCabalFilesFilePath :: IO [FilePath]
findCabalFilesFilePath = do
    dir <- FilePath.getCurrentDirectory
    curDirCabal <- findCabalFileFilePath dir
    dirs <- getSubdirsRecursiveFilePath dir
    subDirsCabals <- mapM findCabalFileFilePath dirs
    pure $ catMaybes $ curDirCabal : subDirsCabals
  where
    -- | Find a @.cabal@ file in the given directory.
    -- TODO: better error handling in stan.
    findCabalFileFilePath :: FilePath -> IO (Maybe FilePath)
    findCabalFileFilePath dir = do
        dirContent <- FilePath.listDirectory dir
        let cabalFiles = filter isCabal dirContent
        pure $ case cabalFiles of
            []            -> Nothing
            cabalFile : _ -> Just $ dir FilePath.</> cabalFile
      where
        isCabal :: FilePath -> Bool
        isCabal p = FilePath.takeExtension p == ".cabal"

    getSubdirsRecursiveFilePath :: FilePath -> IO [FilePath]
    getSubdirsRecursiveFilePath fp = do
        all' <- filter nonGenDir <$> FilePath.listDirectory fp
        dirs <- filterM FilePath.doesDirectoryExist (mkRel <$> all')
        case dirs of
            [] -> pure []
            ds -> do
                -- unsafeInterleaveIO is required here for performance reasons
                next <- unsafeInterleaveIO $ foldMapA getSubdirsRecursiveFilePath ds
                pure $ dirs ++ next
      where
        nonGenDir :: FilePath -> Bool
        nonGenDir d =
               d /= "dist"
            && d /= "dist-newstyle"
            && d /= ".stack-work"
            && d /= ".git"

        mkRel :: FilePath -> FilePath
        mkRel = (fp FilePath.</>)

#else
-- Fix for https://github.com/haskell/haskell-language-server/issues/4515
findCabalFilesStreaming :: IO [FilePath]
findCabalFilesStreaming = do
    setRef <- newIORef S.empty -- stores the directories where we already found 1 cabal file
    root <- OsPath.getCurrentDirectory
    traverse OsPath.decodeFS =<<
        OPS.listContentsRecFold
           Nothing -- Depth limit
            (\_ _ (OPS.Relative _dirRelPath) (OPS.Basename dirBasename)  _symlinkType _consDirToList traverseThisSubdir rest ->
                 if visitCurrSubdirPred dirBasename -- if this condition is satisfied
                 then traverseThisSubdir rest -- True -> then this subdir will be traversed
                 else rest -- False -> else, this subdir will not be traversed
                ) -- how to fold this directory and its children, given its path
            (\_ _ (OPS.Relative path) (OPS.Basename fileBasename) _ft -> do
                  let parentDir = OsPath.takeDirectory path
                  set <- readIORef setRef
                  if not (S.member parentDir set) && collectPred fileBasename -- if this condition is satisfied
                  then do
                        writeIORef setRef $  S.insert parentDir set -- we add the parentDir of this file, to prevent adding more than 1 .cabal file
                        pure (Just path) -- True -> then this path will be added to the results (because @path@ is already relative, we no longer need @mkRel@)
                  else pure Nothing -- False -> else, this path wont be added
                )
            (Identity root) -- (f a), list of roots to search in
  where
    visitCurrSubdirPred :: OsPath.OsPath -> Bool
    visitCurrSubdirPred d =
           d /= [OsPath.osp|dist|]
        && d /= [OsPath.osp|dist-newstyle|]
        && d /= [OsPath.osp|.stack-work|]
        && d /= [OsPath.osp|.git|]

    collectPred :: OsPath.OsPath -> Bool
    collectPred p =
        OsPath.takeExtension p == [OsPath.osp|.cabal|]
#endif

mergeParsedExtensions
    :: Either ExtensionsError ParsedExtensions
    -> Either ExtensionsError ParsedExtensions
    -> ExtensionsResult
mergeParsedExtensions (Left err) _                = Left err
mergeParsedExtensions _ (Left err)                = Left err
mergeParsedExtensions (Right exts1) (Right exts2) = mergeAnyExtensions exts1 exts2