packages feed

cabal-edit-0.1.0.0: exe/Main.hs

{-# LANGUAGE RecordWildCards #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}

module Main
  ( main,
  )
where

import Control.Monad
import qualified Data.ByteString as BS
import Data.List
import Data.Map as Map
import Data.Maybe
import Data.Set as Set
import Data.Store
import Data.Time.Clock
import qualified Distribution.Hackage.DB.Parsed as P
import Distribution.Hackage.DB.Path
import qualified Distribution.Hackage.DB.Unparsed as U
import Distribution.PackageDescription.Parsec
import Distribution.PackageDescription.PrettyPrint
import Distribution.Parsec
import Distribution.Pretty
import Distribution.Types.BuildInfo
import Distribution.Types.CondTree
import Distribution.Types.Dependency
import Distribution.Types.GenericPackageDescription
import Distribution.Types.Library
import Distribution.Types.LibraryName
import Distribution.Types.PackageDescription
import Distribution.Types.PackageName
import Distribution.Types.Version
import Distribution.Types.VersionRange.Internal
import Distribution.Utils.ShortText
import Distribution.Verbosity
import Options.Applicative
import System.Directory
import System.Exit
import System.FilePath.Glob
import System.FilePath.Posix
import System.Process

-------------------------------------------------------------------------------
-- Library Manipulation
-------------------------------------------------------------------------------

buildInfo :: GenericPackageDescription -> Maybe BuildInfo
buildInfo GenericPackageDescription {..} = fmap go condLibrary
  where
    go (CondNode Library {..} _ _) = libBuildInfo

setBuildInfo ::
  BuildInfo ->
  GenericPackageDescription ->
  GenericPackageDescription
setBuildInfo binfo pkg@GenericPackageDescription {..} = pkg {condLibrary = fmap go condLibrary}
  where
    go (CondNode var deps libs) = CondNode (var {libBuildInfo = binfo}) deps libs

addLibDep ::
  Dependency ->
  BuildInfo ->
  BuildInfo
addLibDep dep binfo@BuildInfo {..} = binfo {targetBuildDepends = targetBuildDepends <> [dep]}

setLibDeps ::
  [Dependency] ->
  BuildInfo ->
  BuildInfo
setLibDeps deps binfo@BuildInfo {} = binfo {targetBuildDepends = deps}

hasLib :: GenericPackageDescription -> IO ()
hasLib pkg =
  if hasLibs (packageDescription pkg)
    then pure ()
    else die "Package has no public library. Cannot modify dependencies."

-------------------------------------------------------------------------------
-- Dependency Manipulation
-------------------------------------------------------------------------------

getDeps ::
  GenericPackageDescription ->
  [Dependency]
getDeps pkg = concat (maybeToList depends)
  where
    depends = fmap targetBuildDepends (buildInfo pkg)

setDeps ::
  [Dependency] ->
  GenericPackageDescription ->
  GenericPackageDescription
setDeps deps pkg@GenericPackageDescription {..} = pkg {condLibrary = fmap go condLibrary}
  where
    go (CondNode var@Library {..} libdeps libs) =
      CondNode (var {libBuildInfo = setLibDeps deps libBuildInfo}) libdeps libs

modifyDeps ::
  (PackageName -> Dependency -> Dependency) ->
  GenericPackageDescription ->
  GenericPackageDescription
modifyDeps f pkg = setDeps [f (depPkgName dep) dep | dep <- getDeps pkg] pkg

-------------------------------------------------------------------------------
-- DepMap
-------------------------------------------------------------------------------

udpateDep ::
  GenericPackageDescription ->
  (PackageName -> Dependency -> Maybe Dependency) ->
  PackageName ->
  Map PackageName Dependency
udpateDep pkg f pk = Map.updateWithKey f pk (depMap pkg)

lookupDep :: GenericPackageDescription -> PackageName -> Maybe Dependency
lookupDep pkg pk = Map.lookup pk (depMap pkg)

modifyDep :: GenericPackageDescription -> PackageName -> Dependency -> GenericPackageDescription
modifyDep pkg pk dep = setDepMap (Map.insert pk dep (depMap pkg)) pkg

deleteDep :: GenericPackageDescription -> PackageName -> GenericPackageDescription
deleteDep pkg pk = setDepMap (Map.delete pk (depMap pkg)) pkg

setDepMap :: Map.Map PackageName Dependency -> GenericPackageDescription -> GenericPackageDescription
setDepMap pkm = setDeps (fmap snd (Map.toList pkm))

depMap :: GenericPackageDescription -> Map.Map PackageName Dependency
depMap pkg = Map.fromList [(depPkgName dep, dep) | dep <- getDeps pkg]

-------------------------------------------------------------------------------
-- Dependency Addition
-------------------------------------------------------------------------------

majorBound :: Version -> Version
majorBound = alterVersion $ \numbers -> case numbers of
  [] -> [0, 1]
  [m1] -> [m1]
  (m1 : m2 : _) -> [m1, m2]

add ::
  Dependency ->
  (FilePath, GenericPackageDescription) ->
  IO GenericPackageDescription
add dep (fname, cabalFile) =
  case depVerRange dep of
    AnyVersion -> do
      let pk = depPkgName dep
      verMap <- cacheDeps
      -- Lookup the latest version and use the majorBound of it.
      ver <- case Map.lookup pk verMap of
        Nothing -> die $ "No such package named: " ++ show pk
        Just vers -> pure (maximum vers)
      let dependency = Dependency pk (majorBoundVersion (majorBound ver)) (Set.singleton defaultLibName)
      putStrLn $ "Adding latest dependency: " ++ prettyShow dependency ++ " to " ++ takeFileName fname
      pure $ modifyDep cabalFile pk dependency
    ThisVersion givenVersion -> addVer ThisVersion givenVersion (fname, cabalFile) dep
    LaterVersion givenVersion -> addVer LaterVersion givenVersion (fname, cabalFile) dep
    OrLaterVersion givenVersion -> addVer OrLaterVersion givenVersion (fname, cabalFile) dep
    EarlierVersion givenVersion -> addVer EarlierVersion givenVersion (fname, cabalFile) dep
    WildcardVersion givenVersion -> addVer WildcardVersion givenVersion (fname, cabalFile) dep
    givenVersion -> die $ "Given version is not on available on Hackage." ++ show givenVersion

upgrade ::
  PackageName ->
  Version ->
  (FilePath, GenericPackageDescription) ->
  IO GenericPackageDescription
upgrade pk latest (_, cabalFile) =
  case lookupDep cabalFile pk of
    Nothing -> do
      putStrLn $ "No current dependency on: " ++ prettyShow pk
      die $ "Perhaps you want to run: cabal-edit add " ++ prettyShow pk
    Just dep -> do
      case depVerRange dep of
        LaterVersion prev ->
          if prev < latest
            then do
              let ver' = intersectVersionRanges (orLaterVersion prev) (orEarlierVersion latest)
              let dep' = Dependency (depPkgName dep) ver' (depLibraries dep)
              pure $ modifyDep cabalFile pk dep'
            else do
              putStrLn "Previous version is inconsistent, replacing lower bound."
              replaceVersion dep
        OrLaterVersion prev -> do
          let ver' = intersectVersionRanges (orLaterVersion prev) (orEarlierVersion latest)
          let dep' = Dependency (depPkgName dep) ver' (depLibraries dep)
          pure $ modifyDep cabalFile pk dep'
        AnyVersion -> replaceVersion dep
        WildcardVersion _ -> replaceVersion dep
        ThisVersion _ -> replaceVersion dep
        OrEarlierVersion _ -> replaceVersion dep
        EarlierVersion _ -> replaceVersion dep
        VersionRangeParens _ -> replaceVersion dep
        IntersectVersionRanges lower _ -> do
          if extractLower lower < latest
            then do
              let ver' = intersectVersionRanges lower (orEarlierVersion latest)
              let dep' = Dependency (depPkgName dep) ver' (depLibraries dep)
              pure $ modifyDep cabalFile pk dep'
            else replaceVersion dep
        UnionVersionRanges _ _ -> replaceVersion dep
        MajorBoundVersion prev -> do
          if prev < latest
            then do
              let ver' = intersectVersionRanges (orLaterVersion prev) (orEarlierVersion latest)
              let dep' = Dependency (depPkgName dep) ver' (depLibraries dep)
              pure $ modifyDep cabalFile pk dep'
            else replaceVersion dep
  where
    extractLower :: VersionRange -> Version
    extractLower (LaterVersion ver) = ver
    extractLower (OrLaterVersion ver) = ver
    extractLower _ = version0
    replaceVersion dep = do
      let dep' = Dependency (depPkgName dep) (majorBoundVersion latest) (depLibraries dep)
      pure $ modifyDep cabalFile pk dep'

remove ::
  PackageName ->
  (FilePath, GenericPackageDescription) ->
  IO GenericPackageDescription
remove pk (_, cabalFile) =
  case lookupDep cabalFile pk of
    Nothing -> die $ "No dependency on:  " ++ show pk
    Just _ -> pure $ deleteDep cabalFile pk

addVer ::
  (Version -> VersionRange) ->
  Version ->
  (FilePath, GenericPackageDescription) ->
  Dependency ->
  IO GenericPackageDescription
addVer f givenVersion (fname, cabalFile) dep = do
  let pk = depPkgName dep
  verMap <- cacheDeps
  let dependency = Dependency pk (f givenVersion) (Set.singleton defaultLibName)
  case Map.lookup pk verMap of
    Nothing -> die $ "No such named: " ++ show pk
    Just vers ->
      if givenVersion `elem` vers
        then do
          putStrLn $ "Adding explicit dependency: " ++ prettyShow dependency ++ " to " ++ takeFileName fname
          let dep' = Dependency (depPkgName dep) (majorBoundVersion givenVersion) (depLibraries dep)
          pure $ modifyDep cabalFile pk dep'
        else die $ "Given version is not on available on Hackage." ++ show givenVersion

-------------------------------------------------------------------------------
-- Version Cache
-------------------------------------------------------------------------------

cacheFile :: FilePath
cacheFile = ".cabal-cache.db"

cacheDeps :: IO (Map PackageName [Version])
cacheDeps = do
  cache <- cacheDb
  cacheExists <- doesFileExist cache
  if cacheExists
    then do
      dbContents <- BS.readFile cache
      case decode dbContents of
        Left _ -> die "Corrupted cabal cache file. Run 'cabal-edit rebuild'."
        Right db -> pure db
    else do
      putStrLn "No cache file found, building from HackageDB."
      buildCache

buildCache :: IO (Map PackageName [Version])
buildCache = do
  cache <- cacheDb
  hdb <- hackageTarball
  now <- getCurrentTime
  tdb <- U.readTarball (Just now) hdb
  vers <- forM (Map.toList tdb) $ \(pk, pdata) -> do
    let verMap = Map.keys (P.parsePackageData pk pdata)
    pure (pk, verMap)
  let db = Map.fromList vers
  BS.writeFile cache (encode db)
  pure db

cacheDb :: IO FilePath
cacheDb = do
  home <- getHomeDirectory
  cabalExists <- doesDirectoryExist (home </> ".cabal")
  if cabalExists
    then pure (home </> ".cabal" </> cacheFile)
    else die "No ~/.cabal directory found. Is cabal installed?"

-------------------------------------------------------------------------------
-- Version Linting
-------------------------------------------------------------------------------

lint :: Bool -> (PackageName, Dependency, Version) -> IO ()
lint fix (pk, dep, latest) =
  case depVerRange dep of
    AnyVersion -> putStrLn $ prettyShow pk ++ " : " ++ "Wildcard version detected. Instead use explicit version bounds."
    ThisVersion _ -> putStrLn $ prettyShow pk ++ " : " ++ "Explicit version detected. Considering allowing a range."
    LaterVersion _ -> putStrLn $ prettyShow pk ++ " : " ++ "No upper bound detected. Add an upper bound."
    OrLaterVersion _ -> putStrLn $ prettyShow pk ++ " : " ++ "No upper bound detected. Add an upper bound."
    EarlierVersion ver ->
      case versionNumbers ver of
        (1 : _) -> pure ()
        _ -> putStrLn $ prettyShow pk ++ " : " ++ "Upper bound only for package with >1.0 admits all previous versions. Add a lower bound to major version."
    OrEarlierVersion ver ->
      case versionNumbers ver of
        (1 : _) -> pure ()
        _ -> putStrLn $ prettyShow pk ++ " : " ++ "Upper bound only for package with >1.0 admits all previous versions. Add a lower bound to major version."
    WildcardVersion ver ->
      if Data.List.length (versionNumbers ver) > 2
        then putStrLn $ prettyShow pk ++ " : " ++ "Wildcard on minor version, consider rewriting wildcard to major version n.x"
        else pure ()
    MajorBoundVersion ver ->
      if majorUpperBound ver == majorUpperBound latest
        then pure ()
        else putStrLn $ prettyShow pk ++ " : " ++ "Consider ugprading major bound to latest version " ++ prettyShow (majorBound latest)
    UnionVersionRanges _ _ -> pure ()
    IntersectVersionRanges lower upper ->
      case (lower, upper) of
        (LaterVersion lower, EarlierVersion upper) -> lintRange pk lower upper latest
        (OrLaterVersion lower, EarlierVersion upper) -> lintRange pk lower upper latest
        (LaterVersion lower, OrEarlierVersion upper) -> lintRange pk lower upper latest
        (OrLaterVersion lower, OrEarlierVersion upper) -> lintRange pk lower upper latest
        _ -> putStrLn $ prettyShow pk ++ " : " ++ "Upper and lower bounds for range are inconsistent."
    VersionRangeParens _ -> pure ()

lintRange :: PackageName -> Version -> Version -> Version -> IO ()
lintRange pk lower upper latest
  | lower >= upper = putStrLn $ prettyShow pk ++ " : " ++ "Lower bound is greater or equal to than upper bound. Reverse bounds."
  | upper < latest = putStrLn $ prettyShow pk ++ " : " ++ "Consider upgrading upper bound to latest version " ++ prettyShow latest
  | otherwise = pure ()

-------------------------------------------------------------------------------
-- Commands
-------------------------------------------------------------------------------

listCmd :: String -> IO ()
listCmd packName = do
  pk <- case (simpleParsec packName :: Maybe PackageName) of
    Nothing -> die "Invalid package name."
    Just pk -> pure pk
  verMap <- cacheDeps
  vers <- case Map.lookup pk verMap of
    Nothing -> die $ "No such named: " ++ show pk
    Just vers -> pure (sort vers)
  mapM_ (putStrLn . prettyShow) vers

latestCmd :: String -> IO ()
latestCmd packName = do
  pk <- case (simpleParsec packName :: Maybe PackageName) of
    Nothing -> die "Invalid package name."
    Just pk -> pure pk
  verMap <- cacheDeps
  latest <- case Map.lookup pk verMap of
    Nothing -> die $ "No such named: " ++ show pk
    Just vers -> pure (maximum vers)
  let dependency = Dependency pk (majorBoundVersion (majorBound latest)) (Set.singleton defaultLibName)
  putStrLn (prettyShow dependency)

addCmd :: [String] -> IO ()
addCmd = mapM_ addSingle

addSingle :: String -> IO ()
addSingle packName = do
  dep <- case (simpleParsec packName :: Maybe Dependency) of
    Nothing -> die "Invalid dependency version number."
    Just dep -> pure dep
  (fname, cabalFile) <- getCabal
  cabalFile' <- add dep (fname, cabalFile)
  --hasLib cabalFile
  writeGenericPackageDescription fname cabalFile'
  formatCabalFile fname

upgradeCmd :: String -> IO ()
upgradeCmd packName = do
  pk <- case (simpleParsec packName :: Maybe PackageName) of
    Nothing -> die "Invalid package name."
    Just pk -> pure pk
  verMap <- cacheDeps
  latestVer <- case Map.lookup pk verMap of
    Nothing -> die $ "No such named: " ++ show pk
    Just vers -> pure (maximum vers)
  (fname, cabalFile) <- getCabal
  --hasLib cabalFile
  case lookupDep cabalFile pk of
    Nothing -> die $ "No current dependency on: " ++ show pk
    Just _ -> do
      let ver' = majorUpperBound latestVer
      putStrLn $ "Upgrading bounds for " ++ prettyShow pk ++ " to " ++ prettyShow ver'
      --traverse (putStrLn . prettyShow) (depMap cabalFile)
      cabalFile' <- upgrade pk ver' (fname, cabalFile)
      writeGenericPackageDescription fname cabalFile'
  formatCabalFile fname

upgradeAllCmd :: IO ()
upgradeAllCmd = do
  verMap <- cacheDeps
  (fname, cabalFile) <- getCabal
  let pks = fmap depPkgName (getDeps cabalFile)
  forM_ pks $ \pk -> do
    latestVer <- case Map.lookup pk verMap of
      Nothing -> die $ "No such named: " ++ show pk
      Just vers -> pure (maximum vers)
    let ver' = majorUpperBound latestVer
    putStrLn $ "Upgrading bounds for " ++ prettyShow pk ++ " to " ++ prettyShow ver'
    cabalFile <- readGenericPackageDescription normal fname
    cabalFile' <- upgrade pk ver' (fname, cabalFile)
    writeGenericPackageDescription fname cabalFile'
  formatCabalFile fname

lintCmd :: IO ()
lintCmd = do
  verMap <- cacheDeps
  (fname, cabalFile) <- getCabal
  let pks = Map.toList $ depMap cabalFile
  forM_ pks $ \(pk, dep) -> do
    latestVer <- case Map.lookup pk verMap of
      Nothing -> die $ "No such named: " ++ show pk
      Just vers -> pure (maximum vers)
    lint False (pk, dep, latestVer)

removeCmd :: String -> IO ()
removeCmd packName = do
  pk <- case (simpleParsec packName :: Maybe PackageName) of
    Nothing -> die "Invalid package name."
    Just pk -> pure pk
  (fname, cabalFile) <- getCabal
  case lookupDep cabalFile pk of
    Nothing -> die $ "No current dependency on: " ++ show pk
    Just _ -> do
      putStrLn $ "Removing dependency on " ++ prettyShow pk
      cabalFile' <- remove pk (fname, cabalFile)
      writeGenericPackageDescription fname cabalFile'
  formatCabalFile fname

rebuildCmd :: IO ()
rebuildCmd = buildCache >> putStrLn "Done."

extensionsCmd :: IO ()
extensionsCmd = do
  (fname, cabalFile) <- getCabal
  let extensions = fmap defaultExtensions (buildInfo cabalFile)
  case extensions of
    Nothing -> putStrLn $ "No default extensions in " ++ takeFileName fname
    Just exts -> mapM_ (putStrLn . showExt) exts
  where
    showExt ext = "{-# LANGUAGE " ++ prettyShow ext ++ " #-}"

formatCmd :: IO ()
formatCmd = do
  (fname, cabalFile) <- getCabal
  putStrLn $ "Formatting: " ++ takeFileName fname
  writeGenericPackageDescription fname cabalFile
  formatCabalFile fname

getCabal :: IO (FilePath, GenericPackageDescription)
getCabal = do
  cabalFiles <- glob "*.cabal"
  case cabalFiles of
    [] -> die "No cabal file found in current directory."
    [fname] -> do
      pkg <- readGenericPackageDescription normal fname
      pure (fname, pkg)
    _ -> die "Multiple cabal-files found."

formatCabalFile :: FilePath -> IO ()
formatCabalFile fpath = do
  mexe <- findExecutable "cabal-fmt"
  case mexe of
    Just _ -> callCommand $ "cabal-fmt --inplace " ++ fpath
    Nothing -> pure ()

-------------------------------------------------------------------------------
-- Orphan Sinbin
-------------------------------------------------------------------------------

instance Store PackageName

instance Store ShortText

instance Store Version

-------------------------------------------------------------------------------
-- Options Parsing
-------------------------------------------------------------------------------

data Cmd
  = Add [String]
  | List String
  | Upgrade String
  | UpgradeAll
  | Remove String
  | Latest String
  | Format
  | Rebuild
  | Extensions
  | Lint
  deriving (Eq, Show)

completerPacks :: IO [String]
completerPacks = do
  db <- cacheDeps
  pure (unPackageName <$> Map.keys db)

addParse :: [String] -> Parser Cmd
addParse localPackages = Add <$> many (argument str (metavar "PACKAGE" <> completeWith localPackages))

listParse :: [String] -> Parser Cmd
listParse localPackages = List <$> argument str (metavar "PACKAGE" <> completeWith localPackages)

upgradeParse :: [String] -> Parser Cmd
upgradeParse localPackages = Upgrade <$> argument str (metavar "PACKAGE" <> completeWith localPackages)

removeParse :: [String] -> Parser Cmd
removeParse localPackages = Remove <$> argument str (metavar "PACKAGE" <> completeWith localPackages)

latestParse :: [String] -> Parser Cmd
latestParse localPackages = Latest <$> argument str (metavar "PACKAGE" <> completeWith localPackages)

opts :: [String] -> Parser Cmd
opts localPackages =
  subparser $
    mconcat
      [ command "add" (info (addParse localPackages) (progDesc "Add dependency to cabal file.")),
        command "list" (info (listParse localPackages) (progDesc "List available versions from Hackage.")),
        command "upgrade" (info (upgradeParse localPackages) (progDesc "Upgrade bounds for given package.")),
        command "remove" (info (removeParse localPackages) (progDesc "Remove a given package.")),
        command "latest" (info (latestParse localPackages) (progDesc "Get the latest version of a given package.")),
        command "rebuild" (info (pure Rebuild) (progDesc "Rebuild cache.")),
        command "upgradeall" (info (pure UpgradeAll) (progDesc "Upgrade all dependencies.")),
        command "format" (info (pure Format) (progDesc "Format cabal file.")),
        command "extensions" (info (pure Extensions) (progDesc "List all default language extensions pragmas.")),
        command "lint" (info (pure Lint) (progDesc "Lint dependency bounds."))
      ]

main :: IO ()
main = do
  comps <- completerPacks
  let options = info (opts comps <**> helper) idm
  cmd <- customExecParser p options
  case cmd of
    Add deps -> addCmd deps
    List dep -> listCmd dep
    Upgrade dep -> upgradeCmd dep
    Remove dep -> removeCmd dep
    Format -> formatCmd
    Rebuild -> rebuildCmd
    Latest dep -> latestCmd dep
    Extensions -> extensionsCmd
    UpgradeAll -> upgradeAllCmd
    Lint -> lintCmd
  where
    p = prefs showHelpOnEmpty