{-# LANGUAGE NoImplicitPrelude #-} {-# LANGUAGE ViewPatterns #-} module Stackage.Database.PackageInfo ( PackageInfo(..) , Identifier(..) , renderEmail , toPackageInfo , parseCabalBlob , parseCabalBlobMaybe , extractDependencies , extractModuleNames , getSynopsis , isMarkdownFilePath ) where import CMarkGFM import Data.Char (isSpace) import Data.Coerce import Data.Map.Merge.Strict as Map import qualified Data.Text as T import qualified Data.Text.Encoding as T import Distribution.Compiler (CompilerFlavor(GHC)) import Distribution.Package (Dependency(..)) import Distribution.PackageDescription (CondTree(..), Condition(..), ConfVar(..), PackageFlag(..), flagDefault, flagName, FlagName, GenericPackageDescription, author, condExecutables, condLibrary, description, genPackageFlags, homepage, license, maintainer, packageDescription, synopsis) import Distribution.PackageDescription.Parsec (parseGenericPackageDescription, runParseResult) import Distribution.Pretty (prettyShow) import Distribution.System (Arch(X86_64), OS(Linux)) import Distribution.Types.CondTree (CondBranch(..)) import Distribution.Types.Library (exposedModules) import Distribution.Types.PackageDescription (PackageDescription(package)) import Distribution.Types.VersionRange (VersionRange, intersectVersionRanges, normaliseVersionRange, withinRange) import Distribution.Utils.ShortText (fromShortText) import Distribution.Version (simplifyVersionRange) import RIO import qualified RIO.Map as Map import qualified RIO.Map.Unchecked as Map (mapKeysMonotonic) import Stackage.Database.Haddock (renderHaddock) import Stackage.Database.Types (Changelog(..), Readme(..)) import Text.Blaze.Html (Html, preEscapedToHtml, toHtml) import Text.Email.Validate import Types (CompilerP(..), FlagNameP(..), ModuleNameP(..), PackageIdentifierP, PackageNameP(..), SafeFilePath, VersionP(..), VersionRangeP(..), unSafeFilePath, dtDisplay) import Yesod.Form.Fields (Textarea(..)) data PackageInfo = PackageInfo { piSynopsis :: !Text , piDescription :: !Html , piAuthors :: ![Identifier] , piMaintainers :: ![Identifier] , piHomepage :: !(Maybe Text) , piLicenseName :: !Text , piReadme :: !Html , piChangelog :: !Html } toPackageInfo :: GenericPackageDescription -> Maybe Readme -> Maybe Changelog -> PackageInfo toPackageInfo gpd mreadme mchangelog = PackageInfo { piSynopsis = T.pack $ fromShortText $ synopsis pd , piDescription = renderHaddock $ fromShortText (description pd) , piReadme = maybe mempty (\(Readme bs isMarkdown) -> renderContent bs isMarkdown) mreadme , piChangelog = maybe mempty (\(Changelog bs isMarkdown) -> renderContent bs isMarkdown) mchangelog , piAuthors = parseIdentitiesLiberally $ T.pack . fromShortText $ author pd , piMaintainers = parseIdentitiesLiberally $ T.pack . fromShortText $ maintainer pd , piHomepage = case T.strip . T.pack . fromShortText $ homepage pd of "" -> Nothing x -> Just x , piLicenseName = T.pack $ prettyShow $ license pd } where pd = packageDescription gpd renderContent bs isMarkdown = let txt = decodeUtf8With lenientDecode bs in if isMarkdown then preEscapedToHtml $ commonmarkToHtml [optSmart] [extTable, extAutolink] txt else toHtml $ Textarea txt getSynopsis :: GenericPackageDescription -> Text getSynopsis = T.pack . fromShortText . synopsis . packageDescription extractModuleNames :: GenericPackageDescription -> [ModuleNameP] extractModuleNames = maybe [] (coerce . exposedModules . condTreeData) . condLibrary isMarkdownFilePath :: SafeFilePath -> Bool isMarkdownFilePath sfp = case T.split (== '.') $ unSafeFilePath sfp of [_, "md"] -> True [_, "markdown"] -> True _ -> False extractDependencies :: CompilerP -> Map FlagNameP Bool -> GenericPackageDescription -> Map PackageNameP VersionRangeP extractDependencies compiler flags gpd = fmap VersionRangeP $ combineDeps $ maybeToList (getDeps' <$> condLibrary gpd) ++ map (getDeps' . snd) (condExecutables gpd) where getDeps' :: CondTree ConfVar [Dependency] a -> Map PackageNameP VersionRange getDeps' = getDeps (getCheckCond compiler (Map.mapKeysMonotonic unFlagNameP flags) gpd) -- | Parse a cabal blob and throw an error on failure. parseCabalBlob :: ByteString -> GenericPackageDescription parseCabalBlob cabalBlob = case snd $ runParseResult $ parseGenericPackageDescription cabalBlob of Left err -> error $ "Problem parsing cabal blob: " <> show err Right gpd -> gpd parseCabalBlobMaybe :: (MonadIO m, MonadReader env m, HasLogFunc env) => PackageIdentifierP -> ByteString -> m (Maybe GenericPackageDescription) parseCabalBlobMaybe pidp cabalBlob = case snd $ runParseResult $ parseGenericPackageDescription cabalBlob of Left err -> Nothing <$ logError ("Problem parsing cabal blob for '" <> display pidp <> "': " <> displayShow err) Right gpd -> do let pid = package (packageDescription gpd) unless (textDisplay (dtDisplay pid :: Utf8Builder) == textDisplay pidp) $ logError $ "Supplied package identifier: '" <> display pidp <> "' does not match the one in cabal file: '" <> dtDisplay pid pure $ Just gpd getCheckCond :: CompilerP -> Map FlagName Bool -> GenericPackageDescription -> Condition ConfVar -> Bool getCheckCond compiler overrideFlags gpd = go where go (Var (OS os)) = os == Linux -- arbitrary go (Var (Arch arch)) = arch == X86_64 -- arbitrary go (Var (PackageFlag flag)) = fromMaybe False $ Map.lookup flag flags go (Var (Impl flavor range)) = flavor == compilerFlavor && compilerVersion `withinRange` range go (Lit b) = b go (CNot c) = not $ go c go (CAnd x y) = go x && go y go (COr x y) = go x || go y (compilerFlavor, compilerVersion) = case compiler of CompilerGHC ver -> (GHC, unVersionP ver) flags = Map.merge Map.dropMissing -- unknown flags should be discarded Map.preserveMissing -- non-overriden flags stay as default (Map.zipWithMatched (\_flagName new _default -> new)) -- override the flag overrideFlags $ Map.fromList $ map toPair $ genPackageFlags gpd where toPair f = (flagName f, flagDefault f) getDeps :: (Condition ConfVar -> Bool) -> CondTree ConfVar [Dependency] a -> Map PackageNameP VersionRange getDeps checkCond = goTree where goTree (CondNode _data deps comps) = combineDeps $ map (\(Dependency name range _) -> Map.singleton (PackageNameP name) range) deps ++ map goComp comps goComp (CondBranch cond yes no) | checkCond cond = goTree yes | otherwise = maybe Map.empty goTree no combineDeps :: [Map PackageNameP VersionRange] -> Map PackageNameP VersionRange combineDeps = Map.unionsWith (\x -> normaliseVersionRange . simplifyVersionRange . intersectVersionRanges x) -- | An identifier specified in a package. Because this field has -- quite liberal requirements, we often encounter various forms. A -- name, a name and email, just an email, or maybe nothing at all. data Identifier = EmailOnly !EmailAddress -- ^ An email only e.g. jones@example.com | Contact !Text !EmailAddress -- ^ A contact syntax, e.g. Dave Jones | PlainText !Text -- ^ Couldn't parse anything sensible, leaving as-is. deriving (Show,Eq) -- | An author/maintainer field may contain a comma-separated list of -- identifiers. It may be the case that a person's name is written as -- "Einstein, Albert", but we only parse commas when there's an -- accompanying email, so that would be: -- -- Einstein, Albert , Isaac Newton -- -- Whereas -- -- Einstein, Albert, Isaac Newton -- -- Will just be left alone. It's an imprecise parsing because the -- input is wide open, but it's better than nothing: -- -- λ> parseIdentitiesLiberally "Chris Done, Dave Jones , Einstein, Albert, Isaac Newton, Michael Snoyman " -- [PlainText "Chris Done" -- ,Contact "Dave Jones" "chrisdone@gmail.com" -- ,PlainText "Einstein, Albert, Isaac Newton" -- ,Contact "Michael Snoyman" "michael@snoyman.com"] -- -- I think that is quite a predictable and reasonable result. -- parseIdentitiesLiberally :: Text -> [Identifier] parseIdentitiesLiberally = filter (not . emptyPlainText) . map strip . concatPlains . map parseChunk . T.split (== ',') where emptyPlainText (PlainText e) = T.null e emptyPlainText _ = False strip (PlainText t) = PlainText (T.strip t) strip x = x concatPlains = go where go (PlainText x:PlainText y:xs) = go (PlainText (x <> "," <> y) : xs) go (x:xs) = x : go xs go [] = [] -- | Try to parse a chunk into an identifier. -- -- 1. First tries to parse an \"email@domain.com\". -- 2. Then tries to parse a \"Foo \". -- 3. Finally gives up and returns a plain text. -- -- λ> parseChunk "foo@example.com" -- EmailOnly "foo@example.com" -- λ> parseChunk "Dave Jones " -- Contact "Dave Jones" "dave@jones.com" -- λ> parseChunk "" -- PlainText "" -- λ> parseChunk "Hello!" -- PlainText "Hello!" -- parseChunk :: Text -> Identifier parseChunk chunk = case emailAddress (T.encodeUtf8 (T.strip chunk)) of Just email -> EmailOnly email Nothing -> case T.stripPrefix ">" (T.dropWhile isSpace (T.reverse chunk)) of Just rest -> case T.span (/= '<') rest of (T.reverse -> emailStr,this) -> case T.stripPrefix "< " this of Just (T.reverse -> name) -> case emailAddress (T.encodeUtf8 (T.strip emailStr)) of Just email -> Contact (T.strip name) email _ -> plain _ -> plain _ -> plain where plain = PlainText chunk -- | Render email to text. renderEmail :: EmailAddress -> Text renderEmail = T.decodeUtf8 . toByteString