arch-hs-0.16: plan/Plan.hs
{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TupleSections #-}
module Plan (PlanResult (..), PlanProblem (..), planUpdates, planIsReady, prettyPlanResult, comparePlanRevisions) where
import Control.Monad (foldM, forM)
import Data.List (foldl', partition, sortOn)
import qualified Data.Map.Strict as Map
import Data.Maybe (catMaybes, fromMaybe, isJust)
import Data.Ord (Down (..))
import qualified Data.Set as Set
import Distribution.ArchHs.DepCheck (VersionedList, directDependencies)
import Distribution.ArchHs.Exception
import Distribution.ArchHs.ExtraDB (versionInExtra)
import Distribution.ArchHs.Hackage
import Distribution.ArchHs.Internal.Prelude
import Distribution.ArchHs.Local (ghcLibList)
import Distribution.ArchHs.Name (isGHCLibs, isHaskellPackage, toArchLinuxName, toHackageName)
import Distribution.ArchHs.PP
import Distribution.ArchHs.RDepCheck
import Distribution.ArchHs.Types
import Distribution.Version (asVersionIntervals, simplifyVersionRange)
import Plan.Toolchain
type PlanEffects r =
Members
'[ExtraEnv, HackageEnv, RawHackageEnv, KnownGHCVersion, FlagAssignmentsEnv, Trace, DependencyRecord, WithMyErr, Embed IO]
r
data PlanProblem
= DependencyProblem PackageName PackageName VersionRange (Maybe Version)
| ReverseDependencyProblem ArchLinuxName PackageName DepSrc VersionRange Version
| UncheckedCandidate PackageName Version MyException
| UncheckedReverseDependency ArchLinuxName [PackageName] MyException
| UncheckedCompilerTool PackageName PackageName VersionRange
| UnavailableDependency PackageName VersionRange (Maybe Version)
data PlanResult = PlanResult
{ planInstalled :: Map.Map PackageName Version,
planVersions :: Map.Map PackageName Version,
planRequested :: Set.Set PackageName,
planProblems :: [PlanProblem],
planWarnings :: [PlanProblem],
plansTried :: Int,
planToolchain :: Maybe Toolchain,
planRevisionNotes :: [Doc AnsiStyle],
planSearchNotes :: [Doc AnsiStyle]
}
-- Exact requests are checked as given; solving can advance from each minimum.
planUpdates :: PlanEffects r => GHCReleases -> Bool -> [(PackageName, Maybe Version)] -> Sem r (Either String PlanResult)
planUpdates releases solve targets
| null targets = pure $ Left "At least one target is required."
| length names /= Set.size (Set.fromList names) = pure $ Left "Each target must be specified only once."
| any (\name -> name /= "ghc" && isGHCLibs name) names = pure $ Left "GHC bundled libraries cannot be updated independently; request ghc to update the toolchain."
| otherwise = do
installed <- Map.fromList <$> forM names (\name -> (name,) <$> currentVersion name)
choices <- forM targets $ \(name, requested) -> do
newer <- if not solve && requested /= Nothing then pure [] else
if name == "ghc"
then pure [release | release <- Map.keys releases, stableGHCRelease release, release > installed Map.! name]
else getNewerVersions name (installed Map.! name)
pure $ case requested of
Just version
| version < installed Map.! name -> Left $ "Downgrades are not supported: " <> unPackageName name
| name == "ghc", Map.notMember version releases -> Left $ "No upstream bundled-library metadata for GHC " <> prettyShow version
| otherwise -> Right (name, version : [v | solve, v <- newer, v > version])
Nothing -> case newer of
[] -> Left $ if name == "ghc" then "No newer stable GHC release is available in upstream metadata." else "No newer preferred version is available for " <> unPackageName name
first : rest -> Right (name, first : [v | solve, v <- rest])
case sequence choices of
Left err -> pure $ Left err
Right options -> do
initial <- selectToolchain releases (Map.fromList [(name, first) | (name, first : _) <- options]) emptyDependencies
let bundled = Set.unions [Map.keysSet packages | release <- fromMaybe [] $ lookup "ghc" options, Just packages <- [Map.lookup release releases]]
if any (\name -> name /= "ghc" && (Set.member name bundled || fixedPackage initial name)) names
then pure $ Left "Bundled libraries and compiler tools cannot be updated independently of the requested GHC releases."
else Right <$> search releases solve installed (Map.fromList options)
where
names = fst <$> targets
currentVersion :: Members '[ExtraEnv, WithMyErr] r => PackageName -> Sem r Version
currentVersion name = do
raw <- versionInExtra $ if name == "ghc" then ArchLinuxName "ghc" else toArchLinuxName name
maybe (throw $ VersionNoParse raw) pure $ simpleParsec raw
data DependencyCache = DependencyCache
{ cachedDependencies :: Map.Map (Version, PackageName, Version) (Either MyException (VersionedList, VersionedList)),
cachedExistingDependencies :: Map.Map PackageName ExistingDependencies,
cachedToolchain :: Maybe Toolchain
}
type ExistingDependencies = Map.Map (DepSrc, PackageName) (VersionRange, Maybe Version)
type ReverseChecks = [(PackageName, [ReverseDep], [SkippedReverseDep])]
emptyDependencies :: DependencyCache
emptyDependencies = DependencyCache Map.empty Map.empty Nothing
selectToolchain :: PlanEffects r => GHCReleases -> Map.Map PackageName Version -> DependencyCache -> Sem r DependencyCache
selectToolchain releases selected cache = case Map.lookup "ghc" selected of
Nothing -> pure cache
Just release -> do
compiler <- ask @Version
extra <- ask @ExtraDB
let packages = releases Map.! release
libraryNames = Map.fromList [(toArchLinuxName name, name) | name <- ghcLibList <> concatMap Map.keys (Map.elems releases)]
provided =
[ _pdName dependency
| name <- ["ghc", "ghc-libs"],
Just desc <- [Map.lookup (ArchLinuxName name) extra],
dependency <- _provides desc,
isHaskellPackage $ _pdName dependency
]
libraries = Set.fromList [name | providedName <- provided, Just name <- [Map.lookup providedName libraryNames]]
tools = Set.fromList [toHackageName name | name <- provided, Map.notMember name libraryNames]
names = Set.unions [libraries, Set.fromList ghcLibList, Map.keysSet packages, Map.keysSet $ Map.findWithDefault Map.empty compiler releases]
installed <- fmap (Map.fromList . catMaybes) $ forM (Set.toList names) $ \name -> do
found <- try @MyException $ currentVersion name
case found of
Right version -> pure $ Just (name, version)
Left (PkgNotFound _) -> pure $ (name,) <$> (Map.lookup compiler releases >>= Map.lookup name)
Left err -> throw err
pure cache {cachedToolchain = Just $ Toolchain release packages installed tools}
fixedPackage :: DependencyCache -> PackageName -> Bool
fixedPackage cache name = maybe (isGHCLibs name) (`toolchainContains` name) $ cachedToolchain cache
unknownCompilerTool :: DependencyCache -> PackageName -> Bool
unknownCompilerTool cache name = maybe False (Set.member name . toolchainTools) $ cachedToolchain cache
availableVersion :: PlanEffects r => DependencyCache -> Map.Map PackageName Version -> PackageName -> Sem r (Maybe Version)
availableVersion cache selected name
| Just toolchain <- cachedToolchain cache, toolchainContains toolchain name = pure $ Map.lookup name $ toolchainPackages toolchain
| Just version <- Map.lookup name selected = pure $ Just version
| otherwise = do
found <- try @MyException $ currentVersion name
case found of
Right version -> pure $ Just version
Left (PkgNotFound _) -> pure Nothing
Left err -> throw err
data SearchCache = SearchCache
{ searchInstalled :: Map.Map PackageName Version,
searchChoices :: Map.Map PackageName [Version],
searchDependencies :: DependencyCache,
searchReverseChecks :: Map.Map (Set.Set PackageName) ReverseChecks,
searchReversePackages :: Map.Map ArchLinuxName (Set.Set ArchLinuxName),
searchRetainable :: Map.Map (PackageName, Int, PackageName, Maybe Version) Bool,
searchRequiredRanges :: Map.Map PackageName VersionRange,
searchConflicts :: Map.Map PackageName PlanProblem
}
externalChecks :: PlanEffects r => [PackageName] -> Map.Map ArchLinuxName (Set.Set ArchLinuxName) -> DependencyCache -> Sem r (ReverseChecks, DependencyCache)
externalChecks targets reversePackages cache = do
let names = Set.toList $ Set.filter ((`notElem` targets) . toHackageName) $
Set.unions [Map.findWithDefault Set.empty (toArchLinuxName target) reversePackages | target <- targets]
(checked, cache') <- foldM
(\(results, known) name -> do
current <- try @MyException $ currentVersion $ toHackageName name
case current of
Left err -> pure ((name, Left err) : results, known)
Right version -> do
(deps, known') <- loadDependencies (toHackageName name) version known
pure ((name, deps) : results, known'))
([], cache)
names
pure
( [ ( target,
[ ReverseDep name ranges
| (name, Right (depends, makeDepends)) <- checked,
let ranges = [(src, range) | (src, deps) <- [(Run, depends), (Make, makeDepends)], (dependency, range) <- deps, dependency == target],
not $ null ranges
],
[ SkippedReverseDep name err
| (name, Left err) <- checked,
Set.member name $ Map.findWithDefault Set.empty (toArchLinuxName target) reversePackages
]
)
| target <- targets
],
cache'
)
search ::
PlanEffects r =>
GHCReleases ->
Bool ->
Map.Map PackageName Version ->
Map.Map PackageName [Version] ->
Sem r PlanResult
search releases solve installed choices = do
extra <- ask @ExtraDB
let reversePackages = Map.fromListWith Set.union
[ (_pdName dependency, Set.singleton $ _name desc)
| desc <- Map.elems extra,
isHaskellPackage $ _name desc,
dependency <- _depends desc <> _makeDepends desc <> _checkDepends desc
]
(requiredRanges, dependencies) <- if updatingGHC then pure (Map.empty, emptyDependencies) else requestedRanges choices emptyDependencies
let initial = SearchCache installed choices dependencies Map.empty reversePackages Map.empty requiredRanges Map.empty
(conflict, directCache) <- if solve && not updatingGHC then propagateRanges False (Map.keysSet choices) initial else pure (Nothing, initial)
cache <- case conflict of
Nothing | solve && not updatingGHC -> snd <$> propagateRanges True (Map.keysSet choices) directCache
_ -> pure directCache
case conflict of
Nothing -> go
(Map.singleton (0 :: Int, Down (0 :: Int), start) Nothing)
(Set.singleton start)
cache 0 Nothing
Just problem -> do
let selected = versions choices start
(reverseChecks, known) <- externalChecks (Map.keys selected) reversePackages (searchDependencies cache)
(problems, warnings, _) <- checkSet installed selected reverseChecks known
pure $ PlanResult installed selected (Map.keysSet choices) (problem : problems) warnings 1 Nothing [] []
where
updatingGHC = Map.member "ghc" choices
start = Map.map (const 0) choices
versions catalog indices = Map.mapWithKey (\name index -> catalog Map.! name !! index) indices
go queue visited cache tried best =
case Map.minViewWithKey queue of
Nothing -> case best of
Just (_, result) -> finish tried cache result
Nothing -> error "planner search starts with one candidate set"
Just (((estimate, Down cost, _), Just (result, neighbors)), remaining) ->
continue estimate cost remaining visited cache tried best result neighbors
Just (((estimate, Down cost, indices), Nothing), remaining) -> do
toolchainDependencies <- selectToolchain releases (versions (searchChoices cache) indices) (searchDependencies cache)
let selected = Map.filterWithKey (\name _ -> name == "ghc" || not (fixedPackage toolchainDependencies name)) $ versions (searchChoices cache) indices
targets = Map.keysSet selected
(reverseChecks, dependencies) <- if updatingGHC then pure ([], toolchainDependencies) else case Map.lookup targets (searchReverseChecks cache) of
Just cached -> pure (cached, searchDependencies cache)
Nothing -> externalChecks (Map.keys selected) (searchReversePackages cache) (searchDependencies cache)
(problems, warnings, dependencies') <- checkSet (searchInstalled cache) selected reverseChecks dependencies
let cache' = cache
{ searchDependencies = dependencies',
searchReverseChecks = Map.insert targets reverseChecks (searchReverseChecks cache)
}
result = PlanResult
{ planInstalled = Map.filterWithKey (\name _ -> Map.member name selected) (searchInstalled cache),
planVersions = selected,
planRequested = Map.keysSet choices,
planProblems = problems,
planWarnings = warnings,
plansTried = tried + 1,
planToolchain = cachedToolchain dependencies',
planRevisionNotes = [],
planSearchNotes = []
}
best' = case best of
Just (previousCost, previous)
| (length (planProblems previous), previousCost) <= (length problems, cost) -> best
_ -> Just (cost, result)
case problems of
[] -> pure result
-- Once propagation proves the whole update impossible, report the
-- checked starting set and the conflict. Optimizing partial sets
-- cannot produce a working plan and can grow exponentially.
_ | not $ Map.null $ searchConflicts cache' -> finish (tried + 1) cache' result
_ -> do
(neighbors, cache'') <- foldM (advance indices) ([], cache') (nub $ ["ghc" | updatingGHC] <> concatMap problemTargets problems)
let movable = Set.fromList $ fst <$> neighbors
(repairs, cache''') <- if updatingGHC
then pure
( filter (not . Set.null) [Set.intersection movable $ Set.fromList $ "ghc" : problemTargets problem | problem <- problems],
cache''
)
else repairChoices movable indices problems cache''
let lowerBound = max (requiredSteps indices cache''') (remainingSteps movable repairs)
priority = max estimate (cost + lowerBound)
-- Every complete solution must repair this clause. Keep all
-- of its alternatives, without enumerating interleavings of
-- unrelated repairs. Prefer small clauses and preserve the
-- requested versions when either choice costs the same.
branchKey clause = (Set.size clause, not $ Set.null $ Set.intersection (Map.keysSet choices) clause, Set.toList clause)
next = case sortOn branchKey repairs of
[] -> []
clause : _ -> [nextState | (name, nextState) <- neighbors, Set.member name clause]
-- Refine a queued lower bound before expanding the node. Retain
-- its evaluation so reordering does not repeat metadata checks.
if priority > estimate
then go
(Map.insert (priority, Down cost, indices) (Just (result, next)) remaining)
visited cache''' (tried + 1) best'
else continue priority cost remaining visited cache''' (tried + 1) best' result next
finish tried cache result = pure result
{ plansTried = tried,
planSearchNotes =
[ vsep $ "The required updates cannot all be satisfied:" : (indent 2 . prettyProblem <$> Map.elems (searchConflicts cache))
| not $ Map.null $ searchConflicts cache
]
}
continue estimate cost remaining visited cache tried best result neighbors
| null $ planProblems result = pure result {plansTried = tried}
| otherwise =
let unseen = filter (`Set.notMember` visited) neighbors
-- One release step can reduce the remaining cost by at most one.
priority = max estimate (cost + 1)
in go
(foldr (\indices -> Map.insert (priority, Down (cost + 1), indices) Nothing) remaining unseen)
(foldr Set.insert visited unseen)
cache tried best
advance indices (neighbors, cache) name
| name /= "ghc" && fixedPackage (searchDependencies cache) name = pure (neighbors, cache)
| otherwise =
case Map.lookup name indices of
Just index -> pure
( [(name, Map.adjust (+ 1) name indices) | index + 1 < length (searchChoices cache Map.! name)] <> neighbors,
cache
)
Nothing
| not solve || isGHCLibs name -> pure (neighbors, cache)
| otherwise -> do
cache' <- discoverVersions name cache
pure ([(name, Map.insert name 0 indices) | not $ null $ searchChoices cache' Map.! name] <> neighbors, cache')
-- Disjoint repair choices each require at least one separate release step.
-- Shared dependencies are deliberately counted only once, so the estimate
-- cannot rule out a smaller update set. Unrepairable constraints contribute
-- zero, allowing independent improvements to a blocked plan.
remainingSteps :: Set.Set PackageName -> [Set.Set PackageName] -> Int
remainingSteps movable repairs = snd $ foldl' count (Set.empty, 0) choices
where
choices = sortOn Set.size $ filter (not . Set.null)
[Set.intersection movable repair | repair <- repairs]
count (used, total) candidates
| Set.null $ Set.intersection used candidates = (Set.union used candidates, total + 1)
| otherwise = (used, total)
requiredSteps :: Map.Map PackageName Int -> SearchCache -> Int
requiredSteps indices cache = sum
[ case Map.lookup name indices of
Just current -> firstCost [(index - current, version) | (index, version) <- indexed, index >= current]
Nothing
| maybe False (`withinRange` range) (Map.lookup name $ searchInstalled cache) -> 0
| otherwise -> firstCost [(index + 1, version) | (index, version) <- indexed]
| (name, range) <- Map.toList (searchRequiredRanges cache),
let indexed = zip [0 ..] $ Map.findWithDefault [] name (searchChoices cache)
firstCost candidates = case [cost | (cost, version) <- candidates, withinRange version range] of
cost : _ -> cost
[] -> 0
]
-- If no future owner version accepts the current dependency, updating the
-- dependency is mandatory. Conversely, if no future dependency fits the
-- current range, the owner must change. Both may be necessary for lockstep
-- releases; recognizing that avoids counting them as interchangeable repairs.
repairChoices :: PlanEffects r => Set.Set PackageName -> Map.Map PackageName Int -> [PlanProblem] -> SearchCache -> Sem r ([Set.Set PackageName], SearchCache)
repairChoices movable indices problems cache = foldM repair ([], cache) problems
where
repair (clauses, known) problem = case problem of
DependencyProblem owner dependency range actual -> dependencyRepair clauses known owner dependency range actual
ReverseDependencyProblem owner dependency _ range actual -> dependencyRepair clauses known (toHackageName owner) dependency range (Just actual)
_ -> pure (add [Set.fromList $ problemTargets problem] clauses, known)
-- If a necessary action is impossible, this failure cannot be repaired in
-- this branch. Keep its diagnostic while working on independent failures.
add alternatives clauses =
let actionable = Set.intersection movable <$> alternatives
in if any Set.null actionable then clauses else actionable <> clauses
future name known = drop (nextIndex name) $ Map.findWithDefault [] name (searchChoices known)
nextIndex name = maybe 0 (+ 1) $ Map.lookup name indices
dependencyRepair clauses known owner dependency range actual = do
let key = (owner, nextIndex owner, dependency, actual)
(canKeepDependency, known') <- case Map.lookup key (searchRetainable known) of
Just cached -> pure (cached, known)
Nothing -> do
(compatible, dependencies) <- acceptsCurrent (searchRequiredRanges known) owner dependency actual (future owner known) (searchDependencies known)
pure (compatible, known
{ searchDependencies = dependencies,
searchRetainable = Map.insert key compatible (searchRetainable known)
})
let allowed = Map.findWithDefault anyVersion dependency (searchRequiredRanges known')
canKeepOwner = any (\v -> withinRange v range && withinRange v allowed) $ future dependency known'
forced = [Set.singleton dependency | not canKeepDependency] <> [Set.singleton owner | not canKeepOwner]
pure (add (if null forced then [Set.fromList [owner, dependency]] else forced) clauses, known')
acceptsCurrent _ _ _ _ [] dependencies = pure (False, dependencies)
acceptsCurrent required owner dependency actual (candidate : rest) dependencies = do
(parsed, dependencies') <- loadDependencies owner candidate dependencies
(existing, dependencies'') <- existingDependencies owner dependencies'
case parsed of
-- Unknown metadata must not strengthen a lower bound.
Left _ -> pure (True, dependencies'')
Right parts
| not (withinRange candidate $ Map.findWithDefault anyVersion owner required)
|| any (\(name, range) -> null $ asVersionIntervals $ intersectVersionRanges range $ Map.findWithDefault anyVersion name required)
(requiredDependencies existing parts) -> acceptsCurrent required owner dependency actual rest dependencies''
| all (\(_, range) -> maybe False (`withinRange` range) actual)
(filter ((== dependency) . fst) $ requiredDependencies existing parts) -> pure (True, dependencies'')
| otherwise -> acceptsCurrent required owner dependency actual rest dependencies''
-- Only dependencies present in every possible release of a requested target
-- constrain the whole search. Their ranges are unions across releases and
-- intersections across targets. These bounds prevent impossible later releases
-- from weakening the estimate for an otherwise mandatory update.
requestedRanges :: PlanEffects r => Map.Map PackageName [Version] -> DependencyCache -> Sem r (Map.Map PackageName VersionRange, DependencyCache)
requestedRanges choices cache = foldM collect (Map.empty, cache) (Map.toList choices)
where
collect (required, known) (name, releases) = do
(existing, known') <- existingDependencies name known
(common, known'') <- foldM
(\(previous, parsed) release -> do
(result, parsed') <- loadDependencies name release parsed
let bounds = case result of
Left _ -> Map.empty
Right parts -> Map.fromListWith intersectVersionRanges $ requiredDependencies existing parts
pure (Just $ maybe bounds (Map.intersectionWith unionVersionRanges bounds) previous, parsed'))
(Nothing, known')
releases
let own = foldr (unionVersionRanges . thisVersion) noVersion releases
bounds = Map.insertWith intersectVersionRanges name own (maybe Map.empty id common)
pure (Map.map simplifyVersionRange $ Map.unionWith intersectVersionRanges required bounds, known'')
-- Propagate only dependencies required by every remaining release. A package
-- that can stay installed does not need its existing dependencies revalidated.
-- Empty domains prove impossibility without enumerating unrelated update sets.
propagateRanges :: PlanEffects r => Bool -> Set.Set PackageName -> SearchCache -> Sem r (Maybe PlanProblem, SearchCache)
propagateRanges includeReverse requested initial = go (Map.keysSet $ searchRequiredRanges initial) Map.empty initial
where
go pending examined cache = case Set.minView pending of
Nothing -> pure (Nothing, cache)
Just (name, rest) -> do
let required = searchRequiredRanges cache Map.! name
current <- case Map.lookup name (searchInstalled cache) of
Just version -> pure $ Just version
Nothing -> do
found <- try @MyException $ currentVersion name
case found of
Right version -> pure $ Just version
Left (PkgNotFound _) -> pure Nothing
Left err -> throw err
let withCurrent = cache {searchInstalled = maybe id (Map.insert name) current (searchInstalled cache)}
if Set.notMember name requested && maybe False (`withinRange` required) current
then go rest examined withCurrent
else do
known <- if isGHCLibs name then pure withCurrent else discoverVersions name withCurrent
let available
| Set.member name requested = searchChoices known Map.! name
| isGHCLibs name = maybe [] pure current
| otherwise = maybe [] pure current <> Map.findWithDefault [] name (searchChoices known)
inRange = filter (`withinRange` required) available
(eligible, known') <- if includeReverse then compatibleReleases name inRange known else pure (inRange, known)
if null eligible
then if includeReverse
then go rest examined $ if null inRange
then known' {searchConflicts = Map.insert name (UnavailableDependency name required current) (searchConflicts known')}
else known'
else pure (Just $ UnavailableDependency name required current, known')
else if Map.lookup name examined == Just eligible
then go rest examined known'
else do
(implied, dependencies) <- requestedRanges (Map.singleton name eligible) (searchDependencies known')
let combined = Map.map simplifyVersionRange $ Map.unionWith intersectVersionRanges (searchRequiredRanges known') implied
forward = known' {searchDependencies = dependencies, searchRequiredRanges = combined}
expanded <- if includeReverse then forceReverseUpdates name eligible current forward else pure forward
let finalRanges = searchRequiredRanges expanded
changed = Map.keysSet $ Map.filterWithKey
(\dependency range -> maybe True ((/= asVersionIntervals range) . asVersionIntervals) $ Map.lookup dependency $ searchRequiredRanges cache)
finalRanges
go (Set.union rest changed) (Map.insert name eligible examined) expanded
compatibleReleases :: PlanEffects r => PackageName -> [Version] -> SearchCache -> Sem r ([Version], SearchCache)
compatibleReleases name releases cache = do
(existing, known) <- existingDependencies name (searchDependencies cache)
(compatible, dependencies) <- foldM
(\(accepted, parsed) version -> do
(result, parsed') <- loadDependencies name version parsed
let allowed = withinRange version $ Map.findWithDefault anyVersion name $ searchRequiredRanges cache
fits = case result of
Left _ -> True
Right parts -> all
(\(dependency, range) -> not $ null $ asVersionIntervals $ intersectVersionRanges range $ Map.findWithDefault anyVersion dependency $ searchRequiredRanges cache)
(requiredDependencies existing parts)
pure ([version | allowed && fits] <> accepted, parsed'))
([], known) releases
pure (reverse compatible, cache {searchDependencies = dependencies})
-- If every possible target release breaks a previously satisfied repository
-- range, that reverse dependent must also update. Unrepairable dependents stay
-- in the normal search so other parts of a blocked plan can still improve.
forceReverseUpdates :: PlanEffects r => PackageName -> [Version] -> Maybe Version -> SearchCache -> Sem r SearchCache
forceReverseUpdates target eligible current initial =
foldM force initial $ Set.toList $ Map.findWithDefault Set.empty (toArchLinuxName target) (searchReversePackages initial)
where
force cache archName
| isGHCLibs (toHackageName archName) = pure cache
| otherwise = do
let owner = toHackageName archName
installed <- try @MyException $ currentVersion owner
case installed of
Left _ -> pure cache
Right version -> do
(result, dependencies) <- loadDependencies owner version (searchDependencies cache)
let known = cache {searchDependencies = dependencies}
case result of
Left _ -> pure known
Right (depends, makeDepends) -> do
let ranges = [range | (name, range) <- depends <> makeDepends, name == target, maybe True (`withinRange` range) current]
if any (\candidate -> all (withinRange candidate) ranges) eligible
then pure known
else do
discovered <- discoverVersions owner known
(releases, checked) <- compatibleReleases owner (Map.findWithDefault [] owner $ searchChoices discovered) discovered
if null releases
then pure checked
else pure checked
{ searchRequiredRanges = Map.insertWith (\a b -> simplifyVersionRange $ intersectVersionRanges a b) owner
(foldr (unionVersionRanges . thisVersion) noVersion releases) (searchRequiredRanges checked)
}
-- Adding a package starts at its next preferred release and costs one step,
-- just like advancing an existing target by one release.
discoverVersions :: PlanEffects r => PackageName -> SearchCache -> Sem r SearchCache
discoverVersions name cache
| Map.member name (searchChoices cache) = pure cache
| otherwise = do
current <- try @MyException $ currentVersion name
installed <- case current of
Right version -> pure $ Just version
Left (PkgNotFound _) -> pure Nothing
Left err -> throw err
newer <- try @MyException $ getNewerVersions name (maybe nullVersion id installed)
available <- case newer of
Right releases -> pure releases
Left (PkgNotFound _) -> pure []
Left err -> throw err
pure cache
{ searchInstalled = maybe id (Map.insert name) installed (searchInstalled cache),
searchChoices = Map.insert name available (searchChoices cache)
}
problemTargets :: PlanProblem -> [PackageName]
problemTargets = \case
DependencyProblem owner dependency _ _ -> [owner, dependency]
ReverseDependencyProblem owner dependency _ _ _ -> [toHackageName owner, dependency]
UncheckedCandidate name _ _ -> [name]
UncheckedReverseDependency owner _ _ -> [toHackageName owner]
UncheckedCompilerTool _ _ _ -> []
UnavailableDependency _ _ _ -> []
checkSet ::
PlanEffects r =>
Map.Map PackageName Version ->
Map.Map PackageName Version ->
ReverseChecks ->
DependencyCache ->
Sem r ([PlanProblem], [PlanProblem], DependencyCache)
checkSet installed selected reverseChecks cache = do
(directProblems, directWarnings, candidateCache) <- checkCandidates False (filter ((/= "ghc") . fst) $ Map.toList selected) cache
(repositoryProblems, repositoryWarnings, cache') <- case cachedToolchain candidateCache of
Nothing -> pure ([], [], candidateCache)
Just toolchain -> do
extra <- ask @ExtraDB
let fixed = toolchainArchPackages toolchain
owners =
[ (name, _version desc)
| desc <- Map.elems extra,
isHaskellPackage $ _name desc,
let name = toHackageName $ _name desc,
Map.notMember name selected,
Set.notMember (_name desc) fixed
]
unreadable = [UncheckedReverseDependency (toArchLinuxName name) ["ghc"] (VersionNoParse raw) | (name, raw) <- owners, Nothing <- [simpleParsec @Version raw]]
(problems, warnings, known) <- checkCandidates True [(name, version) | (name, raw) <- owners, Just version <- [simpleParsec raw]] candidateCache
pure (problems, unreadable <> warnings, known)
let reverseProblems = concat
[ [ ReverseDependencyProblem (reverseDepName dep) target src range (selected Map.! target)
| dep <- deps,
(src, range) <- reverseDepRanges dep,
not $ withinRange (selected Map.! target) range
]
| (target, deps, _) <- reverseChecks
]
unchecked = Map.fromListWith
(\(err, a) (_, b) -> (err, Set.union a b))
[ ((skippedReverseDepName dep, show $ skippedReverseDepError dep), (skippedReverseDepError dep, Set.singleton target))
| (target, _, skipped) <- reverseChecks,
dep <- skipped
]
unverified = [UncheckedReverseDependency name (Set.toList targets) err | ((name, _), (err, targets)) <- Map.toList unchecked]
(existing, introduced) = partition alreadyBroken reverseProblems
alreadyBroken (ReverseDependencyProblem _ target _ range _) =
maybe False (not . (`withinRange` range)) $ Map.lookup target installed
alreadyBroken _ = False
pure (directProblems <> repositoryProblems <> introduced, directWarnings <> repositoryWarnings <> existing <> unverified, cache')
where
checkCandidates _ [] known = pure ([], [], known)
checkCandidates repository ((name, version) : rest) known = do
(dependencies, known') <- loadDependencies name version known
(existing, known'') <- existingDependencies name known'
compiler <- ask @Version
let previous = Map.lookup (compiler, name, version) (cachedDependencies known'') >>= either (const Nothing) Just
problems <- case dependencies of
Left err -> pure [if repository then (True, UncheckedReverseDependency (toArchLinuxName name) ["ghc"] err) else (False, UncheckedCandidate name version err)]
Right parts -> concat <$> forM (tagDependencies parts) (\(src, dependency, range) -> do
actual <- availableVersion known'' selected dependency
let problem = case (repository, actual) of
(True, Just candidate) -> ReverseDependencyProblem (toArchLinuxName name) dependency src range candidate
_ -> DependencyProblem name dependency range actual
pure $ if unknownCompilerTool known'' dependency
then [(True, UncheckedCompilerTool name dependency range)]
else [(dependencyWasBroken previous known'' existing src dependency range, problem) | maybe True (not . (`withinRange` range)) actual])
let (warnings, failures) = partition fst problems
(others, otherWarnings, finalCache) <- checkCandidates repository rest known''
pure ((snd <$> failures) <> others, (snd <$> warnings) <> otherWarnings, finalCache)
loadDependencies ::
PlanEffects r =>
PackageName ->
Version ->
DependencyCache ->
Sem r (Either MyException (VersionedList, VersionedList), DependencyCache)
loadDependencies name version known = do
installedCompiler <- ask @Version
let compiler = maybe installedCompiler toolchainVersion $ cachedToolchain known
(dependencies, cache) <- loadDependenciesWith compiler name version known
baselineCache <- if compiler == installedCompiler then pure cache else snd <$> loadDependenciesWith installedCompiler name version cache
pure (dependencies, baselineCache)
loadDependenciesWith :: PlanEffects r => Version -> PackageName -> Version -> DependencyCache -> Sem r (Either MyException (VersionedList, VersionedList), DependencyCache)
loadDependenciesWith compiler name version known = case Map.lookup (compiler, name, version) (cachedDependencies known) of
Just cached -> pure (cached, known)
Nothing -> do
dependencies <- try @MyException $ do
cabal <- getCabalIncludingDeprecated name version
local @Version (const compiler) $ localDependencyRecord $ directDependencies cabal
pure (dependencies, known {cachedDependencies = Map.insert (compiler, name, version) dependencies (cachedDependencies known)})
tagDependencies :: (VersionedList, VersionedList) -> [(DepSrc, PackageName, VersionRange)]
tagDependencies (depends, makeDepends) =
[(src, name, range) | (src, deps) <- [(Run, depends), (Make, makeDepends)], (name, range) <- deps]
requiredDependencies :: ExistingDependencies -> (VersionedList, VersionedList) -> VersionedList
requiredDependencies existing parts =
[(name, range) | (src, name, range) <- tagDependencies parts, not $ existingDependencyFailure existing src name range]
existingDependencyFailure :: ExistingDependencies -> DepSrc -> PackageName -> VersionRange -> Bool
existingDependencyFailure existing src dependency range = case Map.lookup (src, dependency) existing of
Just (baseline, Just installed) ->
not (withinRange installed baseline)
|| not (null $ asVersionIntervals range)
&& null (asVersionIntervals $ intersectVersionRanges range $ orLaterVersion installed)
Just (_, Nothing) -> True
Nothing -> False
dependencyWasBroken :: Maybe (VersionedList, VersionedList) -> DependencyCache -> ExistingDependencies -> DepSrc -> PackageName -> VersionRange -> Bool
dependencyWasBroken previous cache existing src dependency range = case cachedToolchain cache of
Nothing -> existingDependencyFailure existing src dependency range
Just _
| any (\(source, name, oldRange) -> source == src && name == dependency && asVersionIntervals oldRange == asVersionIntervals range)
(maybe [] tagDependencies previous) -> existingDependencyFailure existing src dependency range
| otherwise -> case Map.lookup (src, dependency) existing of
Just (baseline, Just installed) -> not $ withinRange installed baseline
Just (_, Nothing) -> True
Nothing -> False
-- Compare with the installed owner's metadata and installed dependency
-- versions, never with candidates being explored in the current branch.
existingDependencies :: PlanEffects r => PackageName -> DependencyCache -> Sem r (ExistingDependencies, DependencyCache)
existingDependencies name cache = case Map.lookup name (cachedExistingDependencies cache) of
Just existing -> pure (existing, cache)
Nothing -> do
current <- try @MyException $ currentVersion name
compiler <- ask @Version
(baseline, known) <- case current of
Right version -> loadDependenciesWith compiler name version cache
Left err -> pure (Left err, cache)
dependencies <- case baseline of
Left _ -> pure []
Right parts -> concat <$> forM (tagDependencies parts) (\(src, dependency, range) -> do
installed <- try @MyException $ currentVersion dependency
pure $ case installed of
Right version -> [((src, dependency), (range, Just version))]
Left (PkgNotFound _) -> [((src, dependency), (range, cachedToolchain known >>= Map.lookup dependency . toolchainInstalled))]
_ -> [])
let existing = Map.fromListWith (\(range, installed) (other, _) -> (intersectVersionRanges range other, installed)) dependencies
pure (existing, known {cachedExistingDependencies = Map.insert name existing (cachedExistingDependencies known)})
localDependencyRecord :: Member DependencyRecord r => Sem r a -> Sem r a
localDependencyRecord action = do
saved <- get @(Map.Map PackageName [VersionRange])
put @(Map.Map PackageName [VersionRange]) Map.empty
result <- action
put saved
pure result
revisionOwners :: ExtraDB -> PlanResult -> [(PackageName, Version)]
revisionOwners extra plan = filter (\(name, _) -> name /= "ghc" || not (isJust $ planToolchain plan)) $ Map.toList $ Map.union (planVersions plan) $ Map.fromList
[ (toHackageName $ _name desc, version)
| desc <- owners,
Just version <- [simpleParsec $ _version desc]
]
where
owners = case planToolchain plan of
Nothing -> [desc | target <- Map.keys (planVersions plan), (desc, _) <- reverseDependencyPackages extra target]
Just toolchain -> [desc | desc <- Map.elems extra, isHaskellPackage $ _name desc, Set.notMember (_name desc) $ toolchainArchPackages toolchain]
type RevisionRanges = Map.Map (DepSrc, PackageName) (VersionRange, String, Doc AnsiStyle)
type RevisionView = Either MyException RevisionRanges
comparePlanRevisions :: PlanEffects r => RawHackageDB -> PlanResult -> Sem r PlanResult
comparePlanRevisions original plan = do
extra <- ask @ExtraDB
let emptyCache = emptyDependencies {cachedToolchain = planToolchain plan}
(notes, _, _) <- foldM compareOwner ([], emptyCache, emptyCache) (revisionOwners extra plan)
pure plan {planRevisionNotes = reverse notes}
where
compareOwner (notes, latestCache, originalCache) (owner, version) = do
(latest, latestCache') <- inspectRevision plan owner version latestCache
(revision0, originalCache') <- local @RawHackageDB (const original) $ inspectRevision plan owner version originalCache
pure (maybe notes (: notes) (revisionDifference owner version latest revision0), latestCache', originalCache')
inspectRevision :: PlanEffects r => PlanResult -> PackageName -> Version -> DependencyCache -> Sem r (RevisionView, DependencyCache)
inspectRevision plan owner version cache = do
(parsed, known) <- loadDependencies owner version cache
compiler <- ask @Version
case parsed of
Left err -> pure (Left err, known)
Right parts -> do
let candidate = Map.member owner (planVersions plan)
ranges = Map.fromListWith intersectVersionRanges
[ ((src, dependency), range)
| (src, dependency, range) <- tagDependencies parts,
candidate || isJust (planToolchain plan) || Map.member dependency (planVersions plan)
]
(existing, known') <- if candidate || isJust (planToolchain plan) then existingDependencies owner known else pure (Map.empty, known)
let previous = Map.lookup (compiler, owner, version) (cachedDependencies known') >>= either (const Nothing) Just
checked <- forM (Map.toList ranges) $ \(key@(src, dependency), range) -> do
actual <- try @MyException $ availableVersion known' (planVersions plan) dependency
let old = if candidate || isJust (planToolchain plan)
then dependencyWasBroken previous known' existing src dependency range
else maybe False (not . (`withinRange` range)) (Map.lookup dependency $ planInstalled plan)
(status, doc)
| unknownCompilerTool known' dependency = ("unchecked tool", prettyWarning $ UncheckedCompilerTool owner dependency range)
| otherwise = case actual of
Left err -> ("unchecked", annYellow (viaPretty range) <> line <> indent 2 (annYellow $ "unchecked:" <+> viaShow err))
Right selected
| maybe False (`withinRange` range) selected -> ("ok", annGreen $ viaPretty range <+> parens "ok")
| otherwise ->
let problem = case (candidate, selected) of
(False, Just chosen) -> ReverseDependencyProblem (toArchLinuxName owner) dependency src range chosen
_ -> DependencyProblem owner dependency range selected
label = (if candidate then "dep" else "rdep") <> if old then "-old" else ""
style = if old then annYellow else annRed
details = if old then prettyWarning problem else prettyProblem problem
in (label, style (viaPretty range) <> line <> indent 2 details)
pure (key, (range, status, doc))
pure (Right $ Map.fromList checked, known')
revisionDifference :: PackageName -> Version -> RevisionView -> RevisionView -> Maybe (Doc AnsiStyle)
revisionDifference owner version latest original = case (latest, original) of
(Left _, Left _) -> Nothing
(Right a, Right b) ->
let changed =
[ (key, Map.lookup key a, Map.lookup key b)
| key <- Set.toList $ Set.union (Map.keysSet a) (Map.keysSet b),
outcome (Map.lookup key a) /= outcome (Map.lookup key b)
]
in if null changed then Nothing else Just $ vsep $
header :
[ indent 2 $ vsep
[ pretty src <> colon <+> viaPretty dependency,
indent 2 $ revisionLine annCyan "latest revision" current,
indent 2 $ revisionLine annBlue "revision 0" first
]
| ((src, dependency), current, first) <- changed
]
_ -> Just $ vsep
[ header,
indent 2 $ completeRevision annCyan "latest revision" latest,
indent 2 $ completeRevision annBlue "revision 0" original
]
where
header = annMagneta $ "Revision comparison:" <+> viaPretty owner <+> viaPretty version
outcome = maybe "ok" (\(_, status, _) -> status)
revisionLine style label details = style label <> colon <+> maybe "not required" (\(_, _, doc) -> doc) details
completeRevision style label result = style label <> colon <> line <> indent 2 (case result of
Left err -> annYellow $ "unchecked:" <+> viaShow err
Right ranges
| Map.null ranges -> "No relevant dependencies"
| otherwise -> vsep [pretty src <> colon <+> viaPretty dependency <+> doc | ((src, dependency), (_, _, doc)) <- Map.toList ranges])
-- Missing repository metadata is distinct from a known incompatibility.
-- Candidate metadata remains mandatory because it defines the proposed set.
planIsReady :: PlanResult -> Bool
planIsReady = null . planProblems
prettyPlanResult :: PlanResult -> Doc AnsiStyle
prettyPlanResult result@PlanResult {..} =
vsep $
[ status <> colon,
indent 2 $ vsep
[ viaPretty name <+> maybe "not in repo" viaPretty (Map.lookup name planInstalled) <+> "->" <+> annBold (viaPretty version)
<> (if Set.member name planRequested then mempty else space <> annCyan "(added by solver)")
| (name, version) <- Map.toList planVersions
],
"Candidate sets checked:" <+> pretty plansTried
]
<> [ line <> "Bundled with GHC" <+> viaPretty (toolchainVersion toolchain) <> colon <> line
<> indent 2 (vsep
[ viaPretty name <+> maybe "not in repo" viaPretty installed
<+> "->" <+> maybe "not bundled" viaPretty proposed
| (name, installed, proposed) <- changes
])
| Just toolchain <- [planToolchain],
let changes =
[ (name, installed, proposed)
| name <- Set.toList $ Set.delete "ghc" $ Set.union (Map.keysSet $ toolchainInstalled toolchain) (Map.keysSet $ toolchainPackages toolchain),
let installed = Map.lookup name $ toolchainInstalled toolchain,
let proposed = Map.lookup name $ toolchainPackages toolchain,
installed /= proposed
],
not $ null changes
]
<> (prettyProblem <$> planProblems)
<> (prettyWarning <$> planWarnings)
<> [line <> vsep planSearchNotes | not $ null planSearchNotes]
<> [line <> vsep planRevisionNotes | not $ null planRevisionNotes]
<> [ line <> "Commit message:" <> line <> pretty (intercalate ", " updates)
<> line <> line <> ("genrebuild -H" <> (if isJust planToolchain then " --ignore ghc-static" else mempty)
<+> hsep [if name == "ghc" then "ghc" else pretty $ unArchLinuxName $ toArchLinuxName name | name <- Map.keys planVersions])
| not $ null updates
]
where
updates =
[ unPackageName name <> " " <> prettyShow version
| (name, version) <- Map.toList planVersions,
Map.lookup name planInstalled /= Just version
]
status
| not $ planIsReady result = annRed "Blocked update set"
| any unchecked planWarnings = annYellow "Update plan ready (with unchecked packages)"
| not $ null planWarnings = annYellow "Update plan ready (with warnings)"
| otherwise = annGreen "Update plan ready"
unchecked (UncheckedReverseDependency _ _ _) = True
unchecked (UncheckedCompilerTool _ _ _) = True
unchecked _ = False
prettyProblem :: PlanProblem -> Doc AnsiStyle
prettyProblem = \case
DependencyProblem owner dependency range actual ->
prettyDependency (annRed "dep:") owner dependency range actual
ReverseDependencyProblem owner dependency src range version ->
prettyReverseDependency (annRed "rdep:") owner dependency src range version
UncheckedCandidate name version err ->
annYellow "unchecked:" <+> viaPretty name <+> viaPretty version <> colon <+> viaShow err
UncheckedReverseDependency name targets err ->
annYellow "unchecked rdep:" <+> pretty (unArchLinuxName name) <+> "for" <+> hsep (punctuate comma $ viaPretty <$> targets) <> colon <+> viaShow err
UncheckedCompilerTool owner tool range ->
annYellow "unchecked compiler tool:" <+> viaPretty owner <+> "requires" <+> viaPretty tool <+> viaPretty range
<> comma <+> "upstream bundled-library metadata does not specify its version"
UnavailableDependency name range (Just current) | isGHCLibs name ->
annRed "dep:" <+> viaPretty name <+> "is fixed at" <+> viaPretty current <+> "by the installed GHC"
<> comma <+> "but the required range is" <+> viaPretty range
UnavailableDependency name range current ->
annRed "dep:" <+> "no installed or newer preferred version of" <+> viaPretty name <+> "satisfies" <+> viaPretty range
<> maybe mempty (\version -> comma <+> "repository version is" <+> viaPretty version) current
prettyWarning :: PlanProblem -> Doc AnsiStyle
prettyWarning (DependencyProblem owner dependency range actual) =
annYellow $ prettyDependency "dep-old:" owner dependency range actual
prettyWarning (ReverseDependencyProblem owner dependency src range version) =
annYellow $ prettyReverseDependency "rdep-old:" owner dependency src range version
prettyWarning problem = prettyProblem problem
prettyDependency :: Doc AnsiStyle -> PackageName -> PackageName -> VersionRange -> Maybe Version -> Doc AnsiStyle
prettyDependency label owner dependency range actual =
label <+> viaPretty owner <+> "requires" <+> viaPretty dependency <+> viaPretty range
<> comma <+> maybe "missing from the update set and repository" (\version -> "selected/repository version is" <+> viaPretty version) actual
prettyReverseDependency :: Doc AnsiStyle -> ArchLinuxName -> PackageName -> DepSrc -> VersionRange -> Version -> Doc AnsiStyle
prettyReverseDependency label owner dependency src range version =
label <+> pretty (unArchLinuxName owner) <+> pretty src <+> "requires" <+> viaPretty dependency <+> viaPretty range
<> comma <+> "selected version is" <+> viaPretty version