From 8316f6268a89fff7409d42d0593ff9152bf7d7e1 Mon Sep 17 00:00:00 2001 From: Bodigrim Date: Wed, 4 Sep 2024 20:12:02 +0100 Subject: [PATCH] Initial commit --- .github/workflows/haskell-ci.yml | 208 +++++++++++++++++++++++++++++++ CHANGELOG.md | 3 + LICENSE | 29 +++++ README.md | 5 + app/Main.hs | 75 +++++++++++ fourmolu.yaml | 11 ++ hackage-revdeps.cabal | 72 +++++++++++ src/Hackage/RevDeps.hs | 122 ++++++++++++++++++ 8 files changed, 525 insertions(+) create mode 100644 .github/workflows/haskell-ci.yml create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app/Main.hs create mode 100644 fourmolu.yaml create mode 100644 hackage-revdeps.cabal create mode 100644 src/Hackage/RevDeps.hs diff --git a/.github/workflows/haskell-ci.yml b/.github/workflows/haskell-ci.yml new file mode 100644 index 0000000..893fdd4 --- /dev/null +++ b/.github/workflows/haskell-ci.yml @@ -0,0 +1,208 @@ +# This GitHub workflow config has been generated by a script via +# +# haskell-ci 'github' 'hackage-revdeps.cabal' +# +# To regenerate the script (for example after adjusting tested-with) run +# +# haskell-ci regenerate +# +# For more information, see https://github.com/haskell-CI/haskell-ci +# +# version: 0.19.20240708 +# +# REGENDATA ("0.19.20240708",["github","hackage-revdeps.cabal"]) +# +name: Haskell-CI +on: + - push + - pull_request +jobs: + linux: + name: Haskell-CI - Linux - ${{ matrix.compiler }} + runs-on: ubuntu-20.04 + timeout-minutes: + 60 + container: + image: buildpack-deps:jammy + continue-on-error: ${{ matrix.allow-failure }} + strategy: + matrix: + include: + - compiler: ghc-9.10.1 + compilerKind: ghc + compilerVersion: 9.10.1 + setup-method: ghcup + allow-failure: false + - compiler: ghc-9.8.2 + compilerKind: ghc + compilerVersion: 9.8.2 + setup-method: ghcup + allow-failure: false + - compiler: ghc-9.6.6 + compilerKind: ghc + compilerVersion: 9.6.6 + setup-method: ghcup + allow-failure: false + - compiler: ghc-9.4.8 + compilerKind: ghc + compilerVersion: 9.4.8 + setup-method: ghcup + allow-failure: false + - compiler: ghc-9.2.8 + compilerKind: ghc + compilerVersion: 9.2.8 + setup-method: ghcup + allow-failure: false + fail-fast: false + steps: + - name: apt + run: | + apt-get update + apt-get install -y --no-install-recommends gnupg ca-certificates dirmngr curl git software-properties-common libtinfo5 + mkdir -p "$HOME/.ghcup/bin" + curl -sL https://downloads.haskell.org/ghcup/0.1.30.0/x86_64-linux-ghcup-0.1.30.0 > "$HOME/.ghcup/bin/ghcup" + chmod a+x "$HOME/.ghcup/bin/ghcup" + "$HOME/.ghcup/bin/ghcup" install ghc "$HCVER" || (cat "$HOME"/.ghcup/logs/*.* && false) + "$HOME/.ghcup/bin/ghcup" install cabal 3.12.1.0 || (cat "$HOME"/.ghcup/logs/*.* && false) + env: + HCKIND: ${{ matrix.compilerKind }} + HCNAME: ${{ matrix.compiler }} + HCVER: ${{ matrix.compilerVersion }} + - name: Set PATH and environment variables + run: | + echo "$HOME/.cabal/bin" >> $GITHUB_PATH + echo "LANG=C.UTF-8" >> "$GITHUB_ENV" + echo "CABAL_DIR=$HOME/.cabal" >> "$GITHUB_ENV" + echo "CABAL_CONFIG=$HOME/.cabal/config" >> "$GITHUB_ENV" + HCDIR=/opt/$HCKIND/$HCVER + HC=$("$HOME/.ghcup/bin/ghcup" whereis ghc "$HCVER") + HCPKG=$(echo "$HC" | sed 's#ghc$#ghc-pkg#') + HADDOCK=$(echo "$HC" | sed 's#ghc$#haddock#') + echo "HC=$HC" >> "$GITHUB_ENV" + echo "HCPKG=$HCPKG" >> "$GITHUB_ENV" + echo "HADDOCK=$HADDOCK" >> "$GITHUB_ENV" + echo "CABAL=$HOME/.ghcup/bin/cabal-3.12.1.0 -vnormal+nowrap" >> "$GITHUB_ENV" + HCNUMVER=$(${HC} --numeric-version|perl -ne '/^(\d+)\.(\d+)\.(\d+)(\.(\d+))?$/; print(10000 * $1 + 100 * $2 + ($3 == 0 ? $5 != 1 : $3))') + echo "HCNUMVER=$HCNUMVER" >> "$GITHUB_ENV" + echo "ARG_TESTS=--enable-tests" >> "$GITHUB_ENV" + echo "ARG_BENCH=--enable-benchmarks" >> "$GITHUB_ENV" + echo "HEADHACKAGE=false" >> "$GITHUB_ENV" + echo "ARG_COMPILER=--$HCKIND --with-compiler=$HC" >> "$GITHUB_ENV" + echo "GHCJSARITH=0" >> "$GITHUB_ENV" + env: + HCKIND: ${{ matrix.compilerKind }} + HCNAME: ${{ matrix.compiler }} + HCVER: ${{ matrix.compilerVersion }} + - name: env + run: | + env + - name: write cabal config + run: | + mkdir -p $CABAL_DIR + cat >> $CABAL_CONFIG <> $CABAL_CONFIG < cabal-plan.xz + echo 'f62ccb2971567a5f638f2005ad3173dba14693a45154c1508645c52289714cb2 cabal-plan.xz' | sha256sum -c - + xz -d < cabal-plan.xz > $HOME/.cabal/bin/cabal-plan + rm -f cabal-plan.xz + chmod a+x $HOME/.cabal/bin/cabal-plan + cabal-plan --version + - name: checkout + uses: actions/checkout@v4 + with: + path: source + - name: initial cabal.project for sdist + run: | + touch cabal.project + echo "packages: $GITHUB_WORKSPACE/source/." >> cabal.project + cat cabal.project + - name: sdist + run: | + mkdir -p sdist + $CABAL sdist all --output-dir $GITHUB_WORKSPACE/sdist + - name: unpack + run: | + mkdir -p unpacked + find sdist -maxdepth 1 -type f -name '*.tar.gz' -exec tar -C $GITHUB_WORKSPACE/unpacked -xzvf {} \; + - name: generate cabal.project + run: | + PKGDIR_hackage_revdeps="$(find "$GITHUB_WORKSPACE/unpacked" -maxdepth 1 -type d -regex '.*/hackage-revdeps-[0-9.]*')" + echo "PKGDIR_hackage_revdeps=${PKGDIR_hackage_revdeps}" >> "$GITHUB_ENV" + rm -f cabal.project cabal.project.local + touch cabal.project + touch cabal.project.local + echo "packages: ${PKGDIR_hackage_revdeps}" >> cabal.project + echo "package hackage-revdeps" >> cabal.project + echo " ghc-options: -Werror=missing-methods" >> cabal.project + cat >> cabal.project <> cabal.project.local + cat cabal.project + cat cabal.project.local + - name: dump install plan + run: | + $CABAL v2-build $ARG_COMPILER $ARG_TESTS $ARG_BENCH --dry-run all + cabal-plan + - name: restore cache + uses: actions/cache/restore@v4 + with: + key: ${{ runner.os }}-${{ matrix.compiler }}-${{ github.sha }} + path: ~/.cabal/store + restore-keys: ${{ runner.os }}-${{ matrix.compiler }}- + - name: install dependencies + run: | + $CABAL v2-build $ARG_COMPILER --disable-tests --disable-benchmarks --dependencies-only -j2 all + $CABAL v2-build $ARG_COMPILER $ARG_TESTS $ARG_BENCH --dependencies-only -j2 all + - name: build w/o tests + run: | + $CABAL v2-build $ARG_COMPILER --disable-tests --disable-benchmarks all + - name: build + run: | + $CABAL v2-build $ARG_COMPILER $ARG_TESTS $ARG_BENCH all --write-ghc-environment-files=always + - name: cabal check + run: | + cd ${PKGDIR_hackage_revdeps} || false + ${CABAL} -vnormal check + - name: haddock + run: | + $CABAL v2-haddock --disable-documentation --haddock-all $ARG_COMPILER --with-haddock $HADDOCK $ARG_TESTS $ARG_BENCH all + - name: unconstrained build + run: | + rm -f cabal.project.local + $CABAL v2-build $ARG_COMPILER --disable-tests --disable-benchmarks all + - name: save cache + uses: actions/cache/save@v4 + if: always() + with: + key: ${{ runner.os }}-${{ matrix.compiler }}-${{ github.sha }} + path: ~/.cabal/store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..489d509 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.1 + +* Initial release. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c77745e --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7d62525 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# hackage-revdeps [![Hackage](http://img.shields.io/hackage/v/hackage-revdeps.svg)](https://hackage.haskell.org/package/hackage-revdeps) [![Stackage LTS](http://stackage.org/package/hackage-revdeps/badge/lts)](http://stackage.org/lts/package/hackage-revdeps) [![Stackage Nightly](http://stackage.org/package/hackage-revdeps/badge/nightly)](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. diff --git a/app/Main.hs b/app/Main.hs new file mode 100644 index 0000000..274eb2a --- /dev/null +++ b/app/Main.hs @@ -0,0 +1,75 @@ +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 (Map) +import Data.Map qualified as M +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, execParser, fullDesc, help, helper, info, metavar, progDesc, strArgument) +import Options.Applicative.NonEmpty (some1) +import System.Console.ANSI (hSupportsANSI, hyperlinkCode) +import System.Exit (die) +import System.IO (stdout) + +parseArgs :: Parser (NonEmpty PackageName) +parseArgs = + some1 $ + strArgument $ + metavar "PKGS" + <> help "Package names to scan Hackage for their reverse dependencies" + +main :: IO () +main = do + let desc = "List Hackage reverse dependencies, using local package index. Consider running 'cabal update' beforehand." + args <- + fmap NE.toList $ + execParser $ + info (helper <*> parseArgs) (fullDesc <> progDesc desc) + + 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 + 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 diff --git a/fourmolu.yaml b/fourmolu.yaml new file mode 100644 index 0000000..80ace56 --- /dev/null +++ b/fourmolu.yaml @@ -0,0 +1,11 @@ +indentation: 2 +function-arrows: leading +comma-style: leading +import-export-style: diff-friendly +indent-wheres: true +record-brace-space: true +newlines-between-decls: 1 +haddock-style: single-line +respectful: true +fixities: [] +single-constraint-parens: never diff --git a/hackage-revdeps.cabal b/hackage-revdeps.cabal new file mode 100644 index 0000000..2095803 --- /dev/null +++ b/hackage-revdeps.cabal @@ -0,0 +1,72 @@ +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.10.1 ghc ==9.8.2 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 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, + strict <0.6, + tar <0.7, + text >=2.0 && <2.2 + + if flag(cabal-syntax) + build-depends: Cabal-syntax >=3.8 && <3.13 + + 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 + + if flag(cabal-syntax) + build-depends: Cabal-syntax >=3.8 && <3.13 + + else + build-depends: Cabal <3.7 diff --git a/src/Hackage/RevDeps.hs b/src/Hackage/RevDeps.hs new file mode 100644 index 0000000..eaf2691 --- /dev/null +++ b/src/Hackage/RevDeps.hs @@ -0,0 +1,122 @@ +module Hackage.RevDeps ( + latestReleases, + extractDependencies, +) where + +import Codec.Archive.Tar qualified as Tar +import Codec.Archive.Tar.Index qualified as Tar +import Data.ByteString (ByteString) +import Data.ByteString.Lazy qualified as BL +import Data.Char (isPunctuation, isSpace) +import Data.List (isSuffixOf) +import Data.Map (Map) +import Data.Map qualified as M +import Data.Maybe (mapMaybe) +import Data.Strict.Tuple (Pair (..)) +import Data.Text (Text) +import Data.Text qualified as T +import Data.Text.AhoCorasick.Automaton qualified as Aho +import Data.Text.Encoding (decodeUtf8With) +import Data.Text.Encoding.Error (lenientDecode) +import Data.Text.Unsafe qualified as T +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. + -> IO (Map PackageName ByteString) + -- ^ Map from latest releases to their Cabal files. +latestReleases needles idx = foldCabalFilesInIndex idx mempty go + where + go pkg cnt = + if containsAnyAsWholeWord + (map (decodeUtf8With lenientDecode) needles) + (decodeUtf8With lenientDecode cnt) + then M.insert pkg cnt + else M.delete pkg + +containsAnyAsWholeWord :: [Text] -> Text -> Bool +containsAnyAsWholeWord needles hay = Aho.runText False go machine hay + where + machine = Aho.build (map (\x -> (x, x)) needles) + + 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 + -> a + -> (PackageName -> ByteString -> a -> a) + -> IO a +foldCabalFilesInIndex fp ini action = do + contents <- BL.readFile fp + Right (_ :!: result) <- pure $ Tar.foldlEntries go (0 :!: ini) (Tar.read contents) + pure result + where + go (offset :!: acc) entry = + Tar.nextEntryOffset entry offset + :!: 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 + +-- | 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