packages feed

tricorder-0.5.0.0: src/Tricorder/Session/CabalFile.hs

module Tricorder.Session.CabalFile
    ( CabalFile (..)
    , inputCabalFiles
    , discoverPackages
    , discoverCabalPackages
    , discoverStackPackages
    , readProjectFile
    )
where

import Atelier.Effects.Env (Env)
import Atelier.Effects.FileSystem (FileSystem, doesFileExist, listDirectory, readFileBs)
import Atelier.Effects.FileSystem.Glob (Glob, globDir1)
import Atelier.Effects.Input (Input, input, runInputEff)
import Atelier.Effects.Log (Log)
import Data.Traversable (for)
import Distribution.Fields (Field (..), FieldLine (..), Name (..), readFields)
import Distribution.PackageDescription.Parsec (parseGenericPackageDescriptionMaybe)
import Distribution.Types.GenericPackageDescription (GenericPackageDescription)
import Effectful.Exception (catch, throwIO)
import Effectful.Reader.Static (Reader, ask)
import System.FilePath (normalise, takeExtension, (</>))
import System.FilePath.Glob (compile)
import System.IO.Error (userError)

import Atelier.Effects.Env qualified as Env
import Atelier.Effects.FileSystem qualified as FileSystem
import Atelier.Effects.Log qualified as Log
import Data.ByteString.Char8 qualified as BC
import Data.List qualified as List
import Data.Text qualified as T

import Tricorder.Runtime (ProjectRoot (..))
import Tricorder.Session.StackProject (StackProject (..))


data CabalFile = CabalFile
    { projectFilePath :: FilePath
    , projectPackageDescription :: GenericPackageDescription
    }
    deriving stock (Show)


inputCabalFiles
    :: ( Env :> es
       , FileSystem :> es
       , Glob :> es
       , Input StackProject :> es
       , Log :> es
       , Reader ProjectRoot :> es
       )
    => Eff (Input [CabalFile] : es) a -> Eff es a
inputCabalFiles = runInputEff $ Log.withNamespace "CabalFile" do
    packageRes <- discoverPackages
    case packageRes of
        Left err -> throwIO $ userError $ toString err
        Right projectFilePaths -> do
            (faileds, packageDescriptions) <-
                logFailure
                    $ partitionEithers <$> for projectFilePaths readProjectFile
            unless (null faileds) do
                Log.warn
                    $ "Failed to parse .cabal files for the following packages: "
                        <> T.intercalate ", " (toText <$> faileds)
            pure $ packageDescriptions
  where
    logFailure =
        ( `catch`
            \(e :: SomeException) -> do
                Log.err $ "Failed to read package descriptions"
                Log.err $ show e
                throwIO e
        )


readProjectFile
    :: (FileSystem :> es)
    => FilePath -> Eff es (Either FilePath CabalFile)
readProjectFile projectFilePath = do
    fileExists <- FileSystem.doesFileExist projectFilePath
    if fileExists
        then readFile projectFilePath
        else do
            dirExists <- FileSystem.doesDirectoryExist projectFilePath
            if dirExists
                then do
                    files <- FileSystem.listDirectory projectFilePath
                    let mCabalFile = find (".cabal" `List.isSuffixOf`) files
                    case mCabalFile of
                        Nothing -> pure $ Left projectFilePath
                        Just cabalFile -> readFile $ projectFilePath </> cabalFile
                else
                    pure $ Left projectFilePath
  where
    readFile path = do
        contents <- readFileBs path
        case parseGenericPackageDescriptionMaybe contents of
            Nothing -> pure $ Left path
            Just gpd -> pure $ Right $ CabalFile path gpd


discoverPackages
    :: ( Env :> es
       , FileSystem :> es
       , Glob :> es
       , Input StackProject :> es
       , Reader ProjectRoot :> es
       )
    => Eff es (Either Text [FilePath])
discoverPackages = do
    ProjectRoot projectRoot <- ask
    hasStackYaml <- FileSystem.doesFileExist $ projectRoot </> "stack.yaml"
    if hasStackYaml
        then discoverStackPackages
        else discoverCabalPackages


-- | Discovers `.cabal` files in all locations and formats Cabal itself
-- supports.
discoverCabalPackages
    :: ( Env :> es
       , FileSystem :> es
       , Glob :> es
       , Reader ProjectRoot :> es
       )
    => Eff es (Either Text [FilePath])
discoverCabalPackages = do
    ProjectRoot projectRoot <- ask
    homeCabalFiles <- maybe [] (one . (</> ".cabal/config")) <$> Env.lookupEnv "HOME"
    let projectFilePaths = projectCabalFiles projectRoot <> homeCabalFiles
    projectFiles <- filterM doesFileExist projectFilePaths
    if null projectFiles
        then
            Right <$> cabalFilesIn projectRoot
        else do
            packages <- fmap (find (not . null))
                $ for projectFiles \projectFile -> do
                    contents <- readFileBs projectFile
                    concat
                        <$> traverse
                            (cabalFilesForEntry projectRoot)
                            (projectPackageEntries contents)
            case packages of
                Nothing -> Right <$> cabalFilesIn projectRoot
                Just pkgs -> pure $ Right pkgs
  where
    projectCabalFiles projectRoot =
        (projectRoot </>) <$> ["cabal.project.local", "cabal.project.freeze", "cabal.project"]

    cabalFilesForEntry projectRoot entry
        | hasWildcard entry = do
            matches <- globDir1 (compile entry) projectRoot
            concat <$> traverse resolveMatch matches
        | isCabalFile entry = pure [projectRoot </> entry]
        | otherwise = cabalFilesIn $ normalise $ projectRoot </> entry
      where
        resolveMatch path
            | isCabalFile path = pure [path]
            | otherwise = cabalFilesIn path


discoverStackPackages
    :: (Input StackProject :> es, Reader ProjectRoot :> es)
    => Eff es (Either Text [FilePath])
discoverStackPackages = do
    ProjectRoot projectRoot <- ask
    project <- input
    pure $ Right $ normalise . (projectRoot </>) <$> project.packages


-- | List the @.cabal@ files directly inside a directory.
cabalFilesIn :: (FileSystem :> es) => FilePath -> Eff es [FilePath]
cabalFilesIn dir = do
    entries <- filter isCabalFile <$> listDirectory dir
    pure $ (dir </>) <$> entries


-- | Does a @packages:@ entry contain a glob wildcard?
hasWildcard :: FilePath -> Bool
hasWildcard = elem '*'


-- | Extract the directory/file entries from the @packages:@ field of a
-- @cabal.project@.
projectPackageEntries :: ByteString -> [FilePath]
projectPackageEntries contents =
    case readFields contents of
        Left _ -> []
        Right fields -> concatMap fromField fields
  where
    fromField = \case
        (Field (Name _ name) fieldLines)
            | name == "packages" -> concatMap fromLine fieldLines
            | otherwise -> []
        _ -> []
    fromLine (FieldLine _ bs) =
        fmap BC.unpack $ filter (not . BC.null) $ BC.words $ BC.map dropComma bs

    dropComma ',' = ' '
    dropComma c = c


isCabalFile :: FilePath -> Bool
isCabalFile = (== ".cabal") . takeExtension