packages feed

tilia-0.0.1.0: src/Tilia/Fixity/Cabal.hs

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}

-- | Reading a package's exposed modules out of its @.cabal@ file.
module Tilia.Fixity.Cabal
  ( packageModules,
    cabalFileInArchive,
    cabalFileAtTop,
    entryPosixPath,
    containedModules,
    sourceDirs,
    declaredExtensions,
  )
where

import Codec.Archive.Tar qualified as Tar
import Codec.Archive.Tar.Entry qualified as Tar
import Codec.Compression.GZip qualified as GZip
import Data.ByteString.Lazy qualified as BL
import Data.Char (isSpace)
import Data.List (isSuffixOf)
import Data.List qualified
import Data.Maybe (mapMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as T
import GHC.Driver.Session qualified as GHC
import GHC.LanguageExtensions.Type (Extension)
import Tilia.Pragma (lookupExtension)
import Tilia.Utils (quietly)

-- | The modules a package exposes, read from the @.cabal@ file in its
-- source tarball.
--
-- Nothing if the tarball cannot be read or holds no @.cabal@ file.
packageModules :: FilePath -> IO (Maybe [Text])
packageModules tarball = quietly Nothing $ do
  bytes <- BL.readFile tarball
  pure (containedModules <$> cabalFileInArchive (Tar.read (GZip.decompress bytes)))

-- | The first @.cabal@ file at the top level of an archive.
--
-- Stops as soon as it finds one. The archive is decompressed lazily, so a
-- @.cabal@ near the front costs a fraction of the whole file.
cabalFileInArchive :: Tar.Entries e -> Maybe Text
cabalFileInArchive = \case
  Tar.Next entry rest
    | cabalFileAtTop (entryPosixPath entry),
      Tar.NormalFile content _ <- Tar.entryContent entry ->
        Just (T.decodeUtf8Lenient (BL.toStrict content))
    | otherwise -> cabalFileInArchive rest
  Tar.Done -> Nothing
  Tar.Fail _ -> Nothing

-- | Where an entry sits in its archive, written the way a tar file writes
-- it.
entryPosixPath :: Tar.Entry -> FilePath
entryPosixPath = Tar.fromTarPathToPosixPath . Tar.entryTarPath

-- | Is this the path of a package's own @.cabal@ file?
cabalFileAtTop :: FilePath -> Bool
cabalFileAtTop path = ".cabal" `isSuffixOf` path && depth path == 2
  where
    depth = (1 +) . length . filter (== '/')

-- | Every module a package holds, whether it exposes it or not.
--
-- A package's internals are worth knowing about because its exposed modules
-- pass names on from them: @base@ re-exports from @GHC.Internal.*@, none of
-- which it exposes. Reading only the exposed list leaves those unreachable,
-- and a re-export that cannot be followed is an answer thrown away.
containedModules :: Text -> [Text]
containedModules t =
  modulesUnder "exposed-modules" t <> modulesUnder "other-modules" t

modulesUnder :: Text -> Text -> [Text]
modulesUnder field =
  concatMap moduleNames . fieldsNamed field . T.lines
  where
    moduleNames =
      filter looksLikeModule
        . concatMap (T.split (== ','))
        . T.words

    looksLikeModule m = case T.uncons m of
      Just (c, _) -> c `elem` ['A' .. 'Z']
      Nothing -> False

-- | Every directory a @.cabal@ file's modules could be under.
--
-- The current directory is always among them, whatever @hs-source-dirs@
-- says. This is deliberately more than cabal would look at: a package can
-- keep modules beside its @.cabal@ file and name other directories as well,
-- and a directory too many costs one @stat@ where a directory too few costs
-- a module we cannot resolve.
sourceDirs :: Text -> [Text]
sourceDirs contents = Data.List.nub (named <> ["."])
  where
    named =
      filter (not . T.null)
        . map T.strip
        . concatMap (T.split (== ','))
        . concatMap T.words
        . fieldsNamed "hs-source-dirs"
        $ T.lines contents

-- | The extensions a package puts in force, read from its @.cabal@ file.
declaredExtensions :: Text -> [Extension]
declaredExtensions contents =
  foldl apply baseline named
  where
    ls = T.lines contents
    baseline = case mapMaybe languageNamed (fieldsNamed "default-language" ls) of
      [] -> GHC.languageExtensions Nothing
      editions ->
        Data.List.nub (concatMap (GHC.languageExtensions . Just) editions)
    named =
      concatMap (T.split (== ',')) . concatMap T.words $
        fieldsNamed "default-extensions" ls
    apply acc written = case T.strip written of
      name
        | Just off <- T.stripPrefix "No" name,
          Just extension <- lookupExtension off ->
            filter (/= extension) acc
        | Just extension <- lookupExtension name,
          extension `notElem` acc ->
            acc <> [extension]
        | otherwise -> acc
    languageNamed written =
      lookup (T.unpack (T.strip written)) [(show e, e) | e <- [minBound .. maxBound]]

-- | The values of every field with the given name, wherever it appears and
-- however deeply it is nested.
fieldsNamed :: Text -> [Text] -> [Text]
fieldsNamed name = go . filter (not . commented)
  where
    commented = T.isPrefixOf "--" . T.stripStart

    go = \case
      [] -> []
      (l : ls)
        | Just value <- fieldValue l ->
            let (continued, rest) = span (deeperThan (indentOf l)) ls
             in T.unwords (value : map T.strip continued) : go rest
        | otherwise -> go ls

    fieldValue l =
      let (key, rest) = T.break (== ':') l
       in if T.toLower (T.strip key) == name && not (T.null rest)
            then Just (T.strip (T.drop 1 rest))
            else Nothing

    deeperThan n l = T.null (T.strip l) || indentOf l > n
    indentOf = T.length . T.takeWhile isSpace