tilia-0.0.2.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.
containedModules :: Text -> [Text]
containedModules t =
modulesUnder "exposed-modules" t <> modulesUnder "other-modules" t
where
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.
sourceDirs :: Text -> [Text]
sourceDirs contents = Data.List.nub (named <> ["."])
where
named =
filter (not . T.null)
. fmap 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 : fmap 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