hwm-0.2.0: src/HWM/Domain/Dependencies.hs
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE NoImplicitPrelude #-}
module HWM.Domain.Dependencies
( Dependencies (..),
Dependency (..),
toDependencyList,
fromDependencyList,
mergeDependencies,
normalizeDependencies,
DependencyMap (..),
sortByDependencyHierarchy,
singleDeps,
HasDependencies (..),
collectNormalizedDependencies,
buildDependencyGraph,
MapDeps (..),
DependencyIssue,
reportDependencyIssues,
detectDependencyIssue,
mkCabalDependency,
)
where
import Control.Monad.Error.Class (MonadError (..))
import Data.Aeson
( FromJSON (..),
ToJSON (..),
)
import qualified Data.Map.Strict as Map
import qualified Data.Set as Set
import qualified Data.Text as T
import Distribution.Package (mainLibSet)
import Distribution.PackageDescription (Benchmark (..), BuildInfo (..), Executable (..), GenericPackageDescription, Library (..), TestSuite (..), UnqualComponentName)
import qualified Distribution.PackageDescription as Cabal
import Distribution.Simple (UpperBound (..), VersionRange, asVersionIntervals)
import Distribution.Types.CondTree
import Distribution.Types.GenericPackageDescription (GenericPackageDescription (..))
import Distribution.Version (LowerBound (..), VersionInterval (..))
import qualified Distribution.Version as Cabal
import HWM.Core.Common (Name)
import HWM.Core.Formatting (Format (..), formatTable, subPathSign)
import HWM.Core.Parsing (Parse (..), firstWord)
import HWM.Core.Pkg (IsPkg (..), Pkg (..), PkgName (..), PkgSource (..))
import HWM.Core.Result (Issue (..), IssueDetails (..), MonadIssue (..), Severity (..))
import HWM.Core.Version (fromCabalVersion, toCabalVersion)
import HWM.Domain.Bounds (Bound (..), Bounds (..), Restriction (..), boundsBetter, hasBounds)
import Relude
data Dependency = Dependency
{ hwmDepName :: PkgName,
hwmDepBounds :: Bounds
}
deriving (Show, Eq)
instance Parse Dependency where
parse =
(\(name, txt) -> Dependency <$> parse name <*> parse txt)
. firstWord
instance Format Dependency where
format Dependency {..} = format hwmDepName <> " " <> format hwmDepBounds
newtype Dependencies = Dependencies {unpackDeps :: Map PkgName Bounds}
deriving (Show)
instance Semigroup Dependencies where
(Dependencies a) <> (Dependencies b) = Dependencies (a <> b)
singleDeps :: Dependency -> Dependencies
singleDeps (Dependency hwmDepName bounds) = Dependencies (Map.singleton hwmDepName bounds)
initDependencies :: [Dependency] -> Dependencies
initDependencies = Dependencies . Map.fromList . map toDuple
where
toDuple (Dependency a b) = (a, b)
toDependencyList :: Dependencies -> [Dependency]
toDependencyList (Dependencies m) = map (uncurry Dependency) $ Map.toList m
instance FromJSON Dependencies where
parseJSON v = initDependencies <$> (parseJSON v >>= traverse parse . sort)
instance ToJSON Dependencies where
toJSON = toJSON . formatTable . map format . toDependencyList
fromDependencyList :: [Dependency] -> Dependencies
fromDependencyList = initDependencies
mergeDependencies :: [Dependency] -> [Dependency]
mergeDependencies = Map.elems . foldl' step Map.empty
where
step acc dep =
Map.insertWith prefer (hwmDepName dep) dep acc
prefer new old = if boundsBetter (hwmDepBounds new) (hwmDepBounds old) then new else old
normalizeDependencies :: [Dependency] -> [Dependency]
normalizeDependencies = filter (hasBounds . hwmDepBounds) . mergeDependencies
newtype DependencyMap = DependencyMap (Map PkgName [PkgName])
buildDependencyGraph :: (IsPkg a) => (a -> [Dependency]) -> [a] -> DependencyMap
buildDependencyGraph collectDeps packages = DependencyMap $ Map.fromList [(getPkgName pkg, internalDeps pkg) | pkg <- packages]
where
internalNames = Set.fromList (map getPkgName packages)
internalDeps pkg = mapMaybe selectInternal (collectDeps pkg)
where
selectInternal (Dependency dName _) =
if Set.member dName internalNames then Just dName else Nothing
instance Format DependencyMap where
format graph = T.intercalate "\n" (map (formatTree 0) (toTree graph))
formatTree :: Int -> Tree -> Text
formatTree depth (Node pkg deps) = newLine <> format pkg <> children
where
newLine | depth == 0 = "\n • " | otherwise = "\n " <> T.replicate depth " " <> subPathSign
children = T.intercalate "" (map (formatTree (depth + 1)) deps)
data Tree = Node PkgName [Tree]
toTree :: DependencyMap -> [Tree]
toTree (DependencyMap graph) =
let allPkgs = Map.keysSet graph <> foldMap Set.fromList (Map.elems graph)
dependentPkgs = foldMap Set.fromList (Map.elems graph)
rootPkgs = Set.toList (Set.difference allPkgs dependentPkgs)
in map (buildTree graph Set.empty) rootPkgs
buildTree :: Map PkgName [PkgName] -> Set PkgName -> PkgName -> Tree
buildTree graph visited pkg =
if Set.member pkg visited
then Node pkg []
else
let deps = Map.findWithDefault [] pkg graph
newVisited = Set.insert pkg visited
childTrees = map (buildTree graph newVisited) deps
in Node pkg childTrees
topologicalSort :: DependencyMap -> Either [PkgName] [PkgName]
topologicalSort (DependencyMap graph) = goFunc [] initialZero indegreeMap
where
nodes = Map.keysSet graph <> foldMap Set.fromList (Map.elems graph)
indegreeMap = foldl' updateIndegree baseIndegree (Map.toList graph)
baseIndegree = Map.fromSet (const (0 :: Int)) nodes
updateIndegree acc (_, deps) = foldl' increment acc deps
increment acc dep = Map.insertWith (+) dep 1 acc
initialZero = Set.fromList [pkg | (pkg, deg) <- Map.toList indegreeMap, deg == 0]
goFunc acc zeros indegrees
| Set.null zeros =
case Map.keys (Map.filter (> 0) indegrees) of
[] -> Right (reverse acc)
cycleNodes -> Left cycleNodes
| otherwise =
let (pkg, remainingZeros) = Set.deleteFindMin zeros
neighbours = Map.findWithDefault [] pkg graph
(nextZeros, nextIndegrees) = foldl' reduce (remainingZeros, indegrees) neighbours
in goFunc (pkg : acc) nextZeros nextIndegrees
reduce (zeros, indegrees) neighbour =
let deg = Map.findWithDefault 0 neighbour indegrees - 1
updatedIndegrees = Map.insert neighbour deg indegrees
updatedZeros = if deg == 0 then Set.insert neighbour zeros else zeros
in (updatedZeros, updatedIndegrees)
sortByDependencyHierarchy :: (MonadError Issue m) => DependencyMap -> [Pkg] -> m [Pkg]
sortByDependencyHierarchy graph ns = do
case topologicalSort graph of
Left depCycle ->
let cycleNames = intercalate " -> " (map toString depCycle)
in throwError
Issue
{ issueTopic = "dependency-resolution",
issueSeverity = SeverityError,
issueMessage = fromString $ "Dependency cycle detected: " <> cycleNames,
issueDetails = Nothing
}
Right sortedNames ->
let indexes = Map.fromList (zip sortedNames [0 ..] :: [(PkgName, Int)])
findIndex pkg = Map.findWithDefault maxBound (pkgName pkg) indexes
in pure $ sortOn (Down . findIndex) ns
class HasDependencies a where
collectDependencies :: [Text] -> a -> [([Text], Dependencies)]
instance HasDependencies Dependencies where
collectDependencies scope lib = [(scope, lib)]
instance (HasDependencies a) => HasDependencies (Map Text a) where
collectDependencies scope libs = concatMap (\(name, lib) -> collectDependencies (scope <> [name]) lib) (Map.toList libs)
instance (HasDependencies a) => HasDependencies (Maybe a) where
collectDependencies scope = maybe [] (collectDependencies scope)
collectNormalizedDependencies :: (HasDependencies a) => a -> [Dependency]
collectNormalizedDependencies package = normalizeDependencies (concatMap (toDependencyList . snd) $ collectDependencies [] package)
instance HasDependencies GenericPackageDescription where
collectDependencies xs GenericPackageDescription {..} =
concat
[ collectDependencies (xs <> ["library"]) condLibrary,
collectDependencies (xs <> ["tests"]) condTestSuites,
collectDependencies (xs <> ["executables"]) condExecutables,
collectDependencies (xs <> ["benchmarks"]) condBenchmarks
]
instance (HasDependencies deps, HasDependencies lib) => HasDependencies (CondTree v deps lib) where
collectDependencies path condTree =
collectDependencies path (condTreeConstraints condTree)
<> concatMap (collectDependencies path) (condTreeComponents condTree)
<> collectDependencies path (condTreeData condTree)
instance (HasDependencies a) => HasDependencies [(UnqualComponentName, a)] where
collectDependencies path = concatMap (\(name, info) -> collectDependencies (path <> [format name]) info)
instance (HasDependencies a, HasDependencies d) => HasDependencies (CondBranch v d a) where
collectDependencies path (CondBranch _ d a) = concatMap (collectDependencies path) d <> collectDependencies path a
fromCabalDependency :: Cabal.Dependency -> Dependency
fromCabalDependency (Cabal.Dependency pkgName versionRange _) =
Dependency (PkgName (format $ Cabal.unPackageName pkgName)) (toMinMax versionRange)
depName :: Cabal.Dependency -> Name
depName (Cabal.Dependency pkgName _ _) = format $ Cabal.unPackageName pkgName
fromCabalDependencies :: [Cabal.Dependency] -> Dependencies
fromCabalDependencies = fromDependencyList . map fromCabalDependency
instance HasDependencies [Cabal.Dependency] where
collectDependencies path deps = [(path, fromCabalDependencies deps)]
instance HasDependencies Library where
collectDependencies path deps = collectDependencies path (libBuildInfo deps)
instance HasDependencies TestSuite where
collectDependencies path deps = collectDependencies path (testBuildInfo deps)
instance HasDependencies Executable where
collectDependencies path deps = collectDependencies path (buildInfo deps)
instance HasDependencies Benchmark where
collectDependencies path buildInfo = collectDependencies path (benchmarkBuildInfo buildInfo)
instance HasDependencies Cabal.BuildInfo where
collectDependencies path buildInfo = collectDependencies path (fromCabalDependencies (targetBuildDepends buildInfo))
isInclusive :: Cabal.Bound -> Bool
isInclusive Cabal.InclusiveBound = True
isInclusive Cabal.ExclusiveBound = False
toBounds :: VersionInterval -> [Bound]
toBounds (VersionInterval (LowerBound v lb) NoUpperBound) = [Bound Min (isInclusive lb) $ fromCabalVersion v]
toBounds (VersionInterval (LowerBound v lb) (UpperBound v2 ub)) = [Bound Min (isInclusive lb) $ fromCabalVersion v, Bound Max (isInclusive ub) $ fromCabalVersion v2]
toMinMax :: VersionRange -> Bounds
toMinMax range = do
let intervals = map toBounds (asVersionIntervals range)
case sort (concat intervals) of
[] -> Bounds Nothing Nothing -- -none or empty range
intervals' ->
case (viaNonEmpty head intervals', viaNonEmpty last intervals') of
(Just x, Just y) -> Bounds (Just x) (Just y)
(_, _) -> Bounds Nothing Nothing
type DepsCtx = (PkgSource, [Text])
class MapDeps a where
mapDeps :: (Monad m) => DepsCtx -> (DepsCtx -> Dependencies -> m Dependencies) -> a -> m a
instance MapDeps Dependencies where
mapDeps ctx f = f ctx
instance (MapDeps a) => MapDeps (Map Text a) where
mapDeps (pkg, path) f = Map.traverseWithKey (\name lib -> mapDeps (pkg, path <> [name]) f lib)
instance (MapDeps a) => MapDeps (Maybe a) where
mapDeps ctx f = maybe (pure Nothing) (fmap Just . mapDeps ctx f)
instance MapDeps GenericPackageDescription where
mapDeps (pkg, xs) f GenericPackageDescription {..} = do
coLibrary <- mapDeps (pkg, xs <> ["library"]) f condLibrary
coTestSuites <- mapDeps (pkg, xs <> ["tests"]) f condTestSuites
coExecutables <- mapDeps (pkg, xs <> ["executables"]) f condExecutables
coBenchmarks <- mapDeps (pkg, xs <> ["benchmarks"]) f condBenchmarks
pure GenericPackageDescription {condLibrary = coLibrary, condTestSuites = coTestSuites, condExecutables = coExecutables, condBenchmarks = coBenchmarks, ..}
instance (MapDeps deps, MapDeps lib) => MapDeps (CondTree v deps lib) where
mapDeps ctx f condTree = do
newConstraints <- mapDeps ctx f (condTreeConstraints condTree)
newComponents <- mapM (mapDeps ctx f) (condTreeComponents condTree)
newData <- mapDeps ctx f (condTreeData condTree)
pure condTree {condTreeConstraints = newConstraints, condTreeComponents = newComponents, condTreeData = newData}
instance (MapDeps a, MapDeps d) => MapDeps (CondBranch v d a) where
mapDeps ctx f condBranch = do
idTrue <- mapDeps ctx f (condBranchIfTrue condBranch)
ifFalse <- mapM (mapDeps ctx f) (condBranchIfFalse condBranch)
pure condBranch {condBranchIfTrue = idTrue, condBranchIfFalse = ifFalse}
instance (MapDeps a) => MapDeps [(UnqualComponentName, a)] where
mapDeps ctx f = mapM (\(name, info) -> fmap (name,) (mapDeps (fst ctx, snd ctx <> [format name]) f info))
instance MapDeps Library where
mapDeps ctx f lib = do
newBuildInfo <- mapDeps ctx f (libBuildInfo lib)
pure lib {libBuildInfo = newBuildInfo}
instance MapDeps Executable where
mapDeps ctx f exe = do
newBuildInfo <- mapDeps ctx f (buildInfo exe)
pure exe {buildInfo = newBuildInfo}
instance MapDeps BuildInfo where
mapDeps ctx f buildInfo = do
newDeps <- mapDeps ctx f (targetBuildDepends buildInfo)
pure buildInfo {targetBuildDepends = newDeps}
instance MapDeps TestSuite where
mapDeps ctx f test = do
newBuildInfo <- mapDeps ctx f (testBuildInfo test)
pure test {testBuildInfo = newBuildInfo}
instance MapDeps Benchmark where
mapDeps ctx f bench = do
newBuildInfo <- mapDeps ctx f (benchmarkBuildInfo bench)
pure bench {benchmarkBuildInfo = newBuildInfo}
instance MapDeps [Cabal.Dependency] where
mapDeps ctx f dep = f ctx (fromCabalDependencies dep) >>= toCabalDependencies regsitry
where
regsitry = Map.fromList $ map (\d -> (depName d, d)) dep
toCabalDependencies :: (Monad m) => Map Name Cabal.Dependency -> Dependencies -> m [Cabal.Dependency]
toCabalDependencies registry = mapM (toCabalDependency registry) . toDependencyList
toCabalDependency :: (Monad m) => Map Name Cabal.Dependency -> Dependency -> m Cabal.Dependency
toCabalDependency registry (Dependency name bounds) = do
(Cabal.Dependency n _ c) <- maybe (error "Dependency not found in registry") pure (Map.lookup (format name) registry)
pure (Cabal.Dependency n (toVersionRange bounds) c)
toVersionRange :: Bounds -> VersionRange
toVersionRange (Bounds (Just minB) (Just maxB)) = Cabal.intersectVersionRanges (toRange minB) (toRange maxB)
toVersionRange (Bounds (Just minB) Nothing) = toRange minB
toVersionRange (Bounds Nothing (Just maxB)) = toRange maxB
toVersionRange (Bounds Nothing Nothing) = Cabal.anyVersion
toRange :: Bound -> VersionRange
toRange (Bound Min inc version) = if inc then Cabal.orLaterVersion (toCabalVersion version) else Cabal.laterVersion (toCabalVersion version)
toRange (Bound Max inc version) = if inc then Cabal.orEarlierVersion (toCabalVersion version) else Cabal.earlierVersion (toCabalVersion version)
type DependencyIssue = (Text, PkgName, Bounds, Maybe Bounds)
reportDependencyIssues :: (MonadIssue m, Applicative m) => PkgSource -> [DependencyIssue] -> m ()
reportDependencyIssues source diffs = do
unless (null diffs)
$ injectIssue
Issue
{ issueTopic = pkgSourceName source,
issueMessage = show (length diffs) <> " dependency issue(s)",
issueSeverity = SeverityWarning,
issueDetails =
Just
DependencyIssue
{ issueDependencies = map (\(scope, dName, actual, expected) -> (scope, format dName, format actual, maybe "unknown" format expected)) diffs,
issueFile = pkgSourceFile source
}
}
detectDependencyIssue :: [Text] -> Dependency -> Maybe Bounds -> [DependencyIssue]
detectDependencyIssue path (Dependency dname depBounds) registryBounds = case registryBounds of
Nothing -> [(scope, dname, depBounds, Nothing)]
Just expected -> ([(scope, dname, depBounds, Just expected) | depBounds /= expected])
where
scope = T.intercalate ":" path
mkCabalDependency :: Dependency -> Cabal.Dependency
mkCabalDependency (Dependency (PkgName name) bounds) = Cabal.Dependency (Cabal.mkPackageName (T.unpack name)) (toVersionRange bounds) mainLibSet