hackage-revdeps (empty) → 0.1
raw patch · 7 files changed
+462/−0 lines, 7 filesdep +Cabaldep +Cabal-syntaxdep +alfred-margaret
Dependencies added: Cabal, Cabal-syntax, alfred-margaret, ansi-terminal, base, bytestring, cabal-install-parsers, containers, filepath, hackage-revdeps, optparse-applicative, tar, text, time
Files
- CHANGELOG.md +3/−0
- LICENSE +29/−0
- README.md +5/−0
- app/History.hs +93/−0
- app/Main.hs +91/−0
- hackage-revdeps.cabal +95/−0
- src/Hackage/RevDeps.hs +146/−0
+ CHANGELOG.md view
@@ -0,0 +1,3 @@+## 0.1++* Initial release.
+ LICENSE view
@@ -0,0 +1,29 @@+Copyright (c) 2024, Bodigrim+++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++ * Redistributions of source code must retain the above copyright+ notice, this list of conditions and the following disclaimer.++ * Redistributions in binary form must reproduce the above+ copyright notice, this list of conditions and the following+ disclaimer in the documentation and/or other materials provided+ with the distribution.++ * Neither the name of the copyright holder nor the names of its+ contributors may be used to endorse or promote products derived+ from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,5 @@+# hackage-revdeps [](https://hackage.haskell.org/package/hackage-revdeps) [](http://stackage.org/lts/package/hackage-revdeps) [](http://stackage.org/nightly/package/hackage-revdeps)++Command-line tool to list Hackage reverse dependencies.++It is different from how Hackage itself tracks them: this tool accounts for all package components, including tests and benchmarks, and counts dependencies only across the latest releases. The approach is roughly equivalent to what [packdeps.haskellers.com](https://packdeps.haskellers.com) used to do.
+ app/History.hs view
@@ -0,0 +1,93 @@+{-# LANGUAGE ApplicativeDo #-}+{-# LANGUAGE RecordWildCards #-}++module Main (+ main,+) where++import Cabal.Config (cfgRepoIndex, hackageHaskellOrg, readConfig)+import Data.ByteString.Char8 qualified as B+import Data.Foldable (for_)+import Data.List qualified as L+import Data.List.NonEmpty (NonEmpty)+import Data.List.NonEmpty qualified as NE+import Data.Map.Strict qualified as M+import Data.Time (Day, UTCTime (..), addDays, getCurrentTime)+import Distribution.Types.PackageName (PackageName, unPackageName)+import Hackage.RevDeps (extractDependencies, latestReleases)+import Options.Applicative (Parser, auto, execParser, fullDesc, help, helper, info, long, metavar, option, progDesc, showDefault, strArgument, value)+import Options.Applicative.NonEmpty (some1)+import System.Console.ANSI (hSupportsANSI, hyperlinkCode)+import System.Exit (die)+import System.IO (stdout)++data Config = Config+ { cnfStart :: !Day+ , cnfFinish :: !Day+ , cnfStep :: !Word+ , cnfPackageNames :: !(NonEmpty PackageName)+ }++parseArgs :: Day -> Parser Config+parseArgs today = do+ cnfStart <-+ option auto $+ long "start"+ <> help "Start date, YYYY-MM-DD"+ <> value (read "2006-09-01")+ <> showDefault+ cnfFinish <-+ option auto $+ long "finish"+ <> help "Finish date, YYYY-MM-DD"+ <> value today+ <> showDefault+ cnfStep <-+ option auto $+ long "step"+ <> help "Step in days"+ <> value 1000+ <> showDefault+ cnfPackageNames <-+ some1 $+ strArgument $+ metavar "PKGS"+ <> help "Package names to scan Hackage for their reverse dependencies"+ pure Config {..}++main :: IO ()+main = do+ let desc = "Count Hackage reverse dependencies for given dates, using local package index. Consider running 'cabal update' beforehand."+ today <- utctDay <$> getCurrentTime+ Config {..} <-+ execParser $+ info (helper <*> parseArgs today) (fullDesc <> progDesc desc)+ let args = NE.toList cnfPackageNames+ dates = L.nub $ [cnfStart, addDays (fromIntegral cnfStep) cnfStart .. cnfFinish] ++ [cnfFinish]+ supportsAnsi <- hSupportsANSI stdout++ cnf <- readConfig+ case cfgRepoIndex cnf hackageHaskellOrg of+ Nothing -> die $ hackageHaskellOrg ++ " not found in cabal.config, aborting"+ Just idx -> do+ putStrLn $ unwords $ "Date " : map (showPackage supportsAnsi) args+ let needles = map (B.pack . unPackageName) args+ for_ dates $ \date -> do+ releases <- latestReleases needles idx (Just $ UTCTime date 0)+ let pkgs = fmap (extractDependencies args) releases+ pkgs' = M.mapWithKey (\k v -> M.delete k v) pkgs+ counters = M.unionsWith (+) $ fmap (fmap (const (1 :: Int))) pkgs'+ putStrLn $ unwords $ show date : map (\pkg -> showPair (pkg, M.findWithDefault 0 pkg counters)) args++showPair :: Show v => (PackageName, v) -> String+showPair (k, v) =+ replicate (length (unPackageName k) - length (show v)) ' '+ ++ show v++showPackage :: Bool -> PackageName -> String+showPackage supportsAnsi p =+ if supportsAnsi+ then hyperlinkCode ("https://" ++ hackageHaskellOrg ++ "/package/" ++ xs) xs+ else xs+ where+ xs = unPackageName p
+ app/Main.hs view
@@ -0,0 +1,91 @@+{-# LANGUAGE ApplicativeDo #-}+{-# LANGUAGE RecordWildCards #-}++module Main (+ main,+) where++import Cabal.Config (cfgRepoIndex, hackageHaskellOrg, readConfig)+import Data.ByteString.Char8 qualified as B+import Data.List.NonEmpty (NonEmpty)+import Data.List.NonEmpty qualified as NE+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as M+import Data.Time (Day, UTCTime (..))+import Distribution.Compat.NonEmptySet (singleton)+import Distribution.Pretty (pretty)+import Distribution.Types.Dependency (Dependency (..))+import Distribution.Types.LibraryName (LibraryName (..))+import Distribution.Types.PackageName (PackageName, unPackageName)+import Distribution.Types.VersionRange (VersionRange)+import Hackage.RevDeps (extractDependencies, latestReleases)+import Options.Applicative (Parser, ReadM, auto, execParser, fullDesc, help, helper, info, long, metavar, option, optional, progDesc, strArgument)+import Options.Applicative.NonEmpty (some1)+import System.Console.ANSI (hSupportsANSI, hyperlinkCode)+import System.Exit (die)+import System.IO (stdout)++data Config = Config+ { cnfIndexState :: !(Maybe UTCTime)+ , cnfPackageNames :: !(NonEmpty PackageName)+ }++parseArgs :: Parser Config+parseArgs = do+ cnfIndexState <-+ optional $+ option (fmap (\d -> UTCTime d 0) (auto :: ReadM Day)) $+ long "index-state"+ <> help "Timestamp of index state at which to stop scanning, YYYY-MM-DD"+ cnfPackageNames <-+ some1 $+ strArgument $+ metavar "PKGS"+ <> help "Package names to scan Hackage for their reverse dependencies"+ pure Config {..}++main :: IO ()+main = do+ let desc = "List Hackage reverse dependencies, using local package index. Consider running 'cabal update' beforehand."+ Config {..} <-+ execParser $+ info (helper <*> parseArgs) (fullDesc <> progDesc desc)+ let args = NE.toList cnfPackageNames++ cnf <- readConfig+ case cfgRepoIndex cnf hackageHaskellOrg of+ Nothing -> die $ hackageHaskellOrg ++ " not found in cabal.config, aborting"+ Just idx -> do+ let needles = map (B.pack . unPackageName) args+ releases <- latestReleases needles idx cnfIndexState+ let pkgs = fmap (extractDependencies args) releases+ pkgs' = M.mapWithKey (\k v -> M.delete k v) pkgs+ report $ M.filter (not . null) pkgs'++report :: Map PackageName (Map PackageName VersionRange) -> IO ()+report pkgs = do+ supportsAnsi <- hSupportsANSI stdout+ putStrLn "Reverse dependencies:"+ let prettify (k, v) = pretty $ Dependency k v (singleton LMainLibName)+ pkgs' = fmap (map prettify . M.toAscList) pkgs+ reportTable supportsAnsi pkgs'+ putStrLn "Total count:"+ let counters = M.unionsWith (+) $ fmap (fmap (const (1 :: Int))) pkgs+ reportTable supportsAnsi counters++reportTable :: Show v => Bool -> Map PackageName v -> IO ()+reportTable supportsAnsi kvs = putStrLn $ unlines $ map showPair $ M.toAscList kvs+ where+ longestKey = maximum $ 0 : map (length . unPackageName) (M.keys kvs)+ showPair (k, v) =+ showPackage supportsAnsi k+ ++ replicate (longestKey + 1 - length (unPackageName k)) ' '+ ++ show v++showPackage :: Bool -> PackageName -> String+showPackage supportsAnsi p =+ if supportsAnsi+ then hyperlinkCode ("https://" ++ hackageHaskellOrg ++ "/package/" ++ xs) xs+ else xs+ where+ xs = unPackageName p
+ hackage-revdeps.cabal view
@@ -0,0 +1,95 @@+cabal-version: 2.2+name: hackage-revdeps+version: 0.1+license: BSD-3-Clause+license-file: LICENSE+maintainer: andrew.lelechenko@gmail.com+author: Bodigrim+tested-with:+ ghc ==9.12.1 ghc ==9.10.1 ghc ==9.8.4 ghc ==9.6.6 ghc ==9.4.8+ ghc ==9.2.8++synopsis: List Hackage reverse dependencies+description:+ Command-line tool to list Hackage reverse dependencies.+ It is different from how Hackage itself tracks them:+ this tool accounts for all package components, including+ tests and benchmarks, and counts dependencies only+ across the latest releases. The approach is roughly+ equivalent to what <https://packdeps.haskellers.com> used to do.++category: Development+build-type: Simple+extra-doc-files:+ CHANGELOG.md+ README.md++source-repository head+ type: git+ location: https://github.com/Bodigrim/hackage-revdeps.git++flag cabal-syntax+ default: False++library+ exposed-modules: Hackage.RevDeps+ hs-source-dirs: src+ default-language: GHC2021+ ghc-options: -Wall -Wunused-packages+ build-depends:+ alfred-margaret >=2.0 && <2.2,+ base >=4.16 && <5,+ bytestring <0.13,+ containers <0.8,+ filepath <1.6,+ tar <0.7,+ text >=2.0 && <2.2,+ time <1.15++ if flag(cabal-syntax)+ build-depends: Cabal-syntax >=3.8 && <3.15++ else+ build-depends: Cabal <3.7++executable hackage-revdeps+ main-is: Main.hs+ hs-source-dirs: app+ default-language: GHC2021+ ghc-options: -Wall -Wunused-packages+ build-depends:+ base,+ ansi-terminal >=0.11.3 && <1.2,+ bytestring,+ cabal-install-parsers <0.7,+ containers,+ hackage-revdeps,+ optparse-applicative >=0.16 && <0.19,+ time <1.15++ if flag(cabal-syntax)+ build-depends: Cabal-syntax >=3.8 && <3.15++ else+ build-depends: Cabal <3.7++executable hackage-revdeps-history+ main-is: History.hs+ hs-source-dirs: app+ default-language: GHC2021+ ghc-options: -Wall -Wunused-packages+ build-depends:+ base,+ ansi-terminal >=0.11.3 && <1.2,+ bytestring,+ cabal-install-parsers <0.7,+ containers,+ hackage-revdeps,+ optparse-applicative >=0.16 && <0.19,+ time <1.15++ if flag(cabal-syntax)+ build-depends: Cabal-syntax >=3.8 && <3.15++ else+ build-depends: Cabal <3.7
+ src/Hackage/RevDeps.hs view
@@ -0,0 +1,146 @@+-- | Functions to list Hackage reverse dependencies.+module Hackage.RevDeps (+ latestReleases,+ extractDependencies,+) where++import Codec.Archive.Tar qualified as Tar+import Codec.Archive.Tar.Entry qualified as Tar+import Control.Exception (throwIO)+import Data.ByteString (ByteString)+import Data.ByteString.Lazy qualified as BL+import Data.Char (isPunctuation, isSpace)+import Data.List (isSuffixOf)+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as M+import Data.Maybe (mapMaybe)+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.AhoCorasick.Automaton qualified as Aho+import Data.Text.Encoding (decodeUtf8Lenient)+import Data.Text.Unsafe qualified as T+import Data.Time (UTCTime)+import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds)+import Distribution.Compat.Lens (toListOf)+import Distribution.PackageDescription.Parsec (parseGenericPackageDescriptionMaybe)+import Distribution.Types.BuildInfo (targetBuildDepends)+import Distribution.Types.BuildInfo.Lens qualified as Lens+import Distribution.Types.Dependency (Dependency (..))+import Distribution.Types.PackageName (PackageName, mkPackageName)+import Distribution.Types.VersionRange (VersionRange)+import Distribution.Version (intersectVersionRanges, simplifyVersionRange)+import System.FilePath (isPathSeparator)++-- | Scan Cabal index @01-index.tar@ and return Cabal files+-- of latest releases (not necessarily largest versions), which+-- contain one of the needles as an entire word (separated by spaces+-- or punctuation).+--+-- To avoid ambiguity: we first select the latest releases,+-- then filter them by needles.+latestReleases+ :: [ByteString]+ -- ^ Needles to search in Cabal files.+ -> FilePath+ -- ^ Path to @01-index.tar@.+ -- One can use @Cabal.Config.cfgRepoIndex@ from @cabal-install-parsers@+ -- to obtain it.+ -> Maybe UTCTime+ -- ^ Timestamp of index state at which to stop scanning.+ -> IO (Map PackageName ByteString)+ -- ^ Map from latest releases to their Cabal files.+latestReleases needles idx indexState =+ M.filter (containsAnyAsWholeWord machine . decodeUtf8Lenient)+ <$> getLatestReleases idx indexState+ where+ machine = Aho.build (map ((\x -> (x, x)) . decodeUtf8Lenient) needles)++-- | Strip revisions and releases except the latest one.+getLatestReleases+ :: FilePath+ -> Maybe UTCTime+ -> IO (Map PackageName ByteString)+getLatestReleases idx indexState = foldCabalFilesInIndex idx indexState mempty M.insert++containsAnyAsWholeWord :: Aho.AcMachine Text -> Text -> Bool+containsAnyAsWholeWord machine hay = Aho.runText False go machine hay+ where+ isWordBoundary c = isSpace c || isPunctuation c++ go :: Bool -> Aho.Match Text -> Aho.Next Bool+ go _ (Aho.Match pos val) =+ if startsWithBoundary && endsWithBoundary+ then Aho.Done True+ else Aho.Step False+ where+ pref =+ T.dropEnd (T.length val) $+ T.takeWord8 (fromIntegral (Aho.codeUnitIndex pos)) hay+ startsWithBoundary = maybe True (isWordBoundary . snd) (T.unsnoc pref)+ suff = T.dropWord8 (fromIntegral (Aho.codeUnitIndex pos)) hay+ endsWithBoundary = maybe True (isWordBoundary . fst) (T.uncons suff)++-- | Inspired by @Cabal.Index.foldIndex@ from @cabal-install-parsers@.+foldCabalFilesInIndex+ :: FilePath+ -> Maybe UTCTime+ -> a+ -> (PackageName -> ByteString -> a -> a)+ -> IO a+foldCabalFilesInIndex fp indexState ini action = do+ contents <- BL.readFile fp+ let entries' = Tar.read contents+ entries = case indexState of+ Nothing -> entries'+ Just t ->+ tarTakeWhile+ (\e -> fromIntegral (Tar.entryTime e) <= utcTimeToPOSIXSeconds t)+ entries'+ case Tar.foldlEntries go ini entries of+ Left (err, _) -> throwIO err+ Right res -> pure res+ where+ go acc entry =+ case Tar.entryContent entry of+ Tar.NormalFile contents _ ->+ if isCabalFile then action pkgName bs acc else acc+ where+ bs = BL.toStrict contents+ fpath = Tar.entryPath entry+ isCabalFile = ".cabal" `isSuffixOf` fpath+ pkgName = mkPackageName $ takeWhile (not . isPathSeparator) fpath+ _ -> acc++tarTakeWhile+ :: (Tar.Entry -> Bool)+ -> Tar.Entries a+ -> Tar.Entries a+tarTakeWhile p =+ Tar.foldEntries+ (\x xs -> if p x then Tar.Next x xs else Tar.Done)+ Tar.Done+ Tar.Fail++-- | Scan Cabal file looking for package names,+-- coalescing version bounds from all components and under all conditions.+extractDependencies+ :: [PackageName]+ -- ^ Needles to search.+ -> ByteString+ -- ^ Content of a Cabal file.+ -> Map PackageName VersionRange+ -- ^ Needles found in the Cabal file and their version bounds.+extractDependencies needles = relevantDeps needles . extractDeps++extractDeps :: ByteString -> [Dependency]+extractDeps cnt = case parseGenericPackageDescriptionMaybe cnt of+ Nothing -> mempty+ Just descr -> foldMap targetBuildDepends $ toListOf Lens.traverseBuildInfos descr++relevantDeps :: [PackageName] -> [Dependency] -> Map PackageName VersionRange+relevantDeps needles =+ fmap simplifyVersionRange . M.fromListWith intersectVersionRanges . mapMaybe go+ where+ go (Dependency pkg ver _)+ | pkg `elem` needles = Just (pkg, ver)+ | otherwise = Nothing