packages feed

purl-0.1.0.0: src/Data/Purl.hs

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE GeneralisedNewtypeDeriving #-}
{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE StrictData #-}

-- | This module provides support for the purl specification: <https://github.com/package-url/purl-spec>
module Data.Purl (
    -- * Types
    Purl (..),
    PurlScheme (..),
    PurlType (..),
    PurlNamespace (..),
    PurlName (..),
    PurlVersion (..),
    PurlQualifierKey (..),
    PurlQualifierValue (..),
    PurlSubPath (..),

    -- * Helpers
    newPurl,
    newPurlHackage,
    newPurlGhc,
    newPurlOther,
    addPurlQuantifier,
    addPurlQuantifiers,
    findPurlQuantifiers,

    -- * Parsers
    parsePurl,
    purlP,
    purlSchemeP,
    purlTypeP,
    purlNamespaceP,
    purlNameP,
    purlVersionP,
    purlQualifiersP,
    purlSubPathP,

    -- * Renderers
    purlText,

    -- * instances
    FromJSON (..),
    ToJSON (..),
)
where

import Control.Monad (guard)
import Data.Aeson
import Data.CaseInsensitive (CI, mk, original)
import Data.Char
import Data.List (nub)
import Data.Map.Strict qualified as M
import Data.Maybe (mapMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import GHC.Generics
import Network.HTTP.Types (urlDecode)
import Text.Parsec

-- * Types

{- | Purl representation
scheme:type/namespace/name@version?qualifiers#subpath
-}
data Purl = Purl
    { purlScheme :: PurlScheme
    , purlType :: PurlType
    , purlNamespace :: Maybe PurlNamespace
    , purlName :: PurlName
    , purlVersion :: Maybe PurlVersion
    , purlQualifiers :: M.Map PurlQualifierKey PurlQualifierValue
    , purlSubPath :: Maybe PurlSubPath
    }
    deriving stock (Eq, Show, Generic)

-- | scheme: this is the URL scheme with the constant value of "pkg".
data PurlScheme
    = Pkg
    deriving stock (Eq, Show, Generic)

-- | type: the package "type" or package "protocol" such as maven, npm, nuget, gem, pypi, etc.
data PurlType
    = Hackage
    | Ghc
    | OtherType (CI Text)
    deriving stock (Eq, Show, Generic)

-- | namespace: some name prefix such as a Maven groupid, a Docker image owner, a GitHub user or organization.
newtype PurlNamespace = PurlNamespace {unPurlNamespace :: [Text]}
    deriving newtype (Eq)

instance Show PurlNamespace where
    showsPrec n = showsPrec n . T.intercalate "/" . unPurlNamespace

-- | name: the name of the package.
newtype PurlName = PurlName {unPurlName :: Text}
    deriving newtype (Eq, Show)

-- | version: the version of the package.
newtype PurlVersion = PurlVersion {unPurlVersion :: Text}
    deriving newtype (Eq, Show)

-- | qualifier: extra qualifying data for a package such as an OS, architecture, a distro, etc.
newtype PurlQualifierKey = PurlQualifierKey {unPurlQualifierKey :: Text}
    deriving newtype (Eq, Ord, Show)

-- | qualifier: extra qualifying data for a package such as an OS, architecture, a distro, etc.
newtype PurlQualifierValue = PurlQualifierValue {unPurlQualifierValue :: Text}
    deriving newtype (Eq, Ord, Show)

-- | subpath: extra subpath within a package, relative to the package root.
newtype PurlSubPath = PurlSubPath {unPurlSubPath :: [Text]}
    deriving newtype (Eq)

instance Show PurlSubPath where
    showsPrec n = showsPrec n . T.intercalate "/" . unPurlSubPath

-- * Helpers

-- | Create a minimal 'Purl'
newPurl :: PurlType -> PurlName -> Purl
newPurl pType pName =
    Purl
        { purlScheme = Pkg
        , purlType = pType
        , purlNamespace = Nothing
        , purlName = pName
        , purlVersion = Nothing
        , purlQualifiers = mempty
        , purlSubPath = Nothing
        }

-- | Create a minimal 'Purl' of type 'Hackage'
newPurlHackage :: PurlName -> Purl
newPurlHackage = newPurl Hackage

-- | Create a minimal 'Purl' of type 'GHC'
newPurlGhc :: PurlName -> Purl
newPurlGhc = newPurl Ghc

-- | Create a minimal 'Purl' of type 'Other'
newPurlOther :: Text -> PurlName -> Purl
newPurlOther other = newPurl (OtherType $ mk other)

-- | Prepend a qualifiers
addPurlQuantifier :: PurlQualifierKey -> PurlQualifierValue -> Purl -> Purl
addPurlQuantifier k v = addPurlQuantifiers [(k, v)]

-- | Prepend a list of qualifiers
addPurlQuantifiers :: [(PurlQualifierKey, PurlQualifierValue)] -> Purl -> Purl
addPurlQuantifiers xs p = p{purlQualifiers = M.fromList xs <> purlQualifiers p}

-- | Find all the 'PurlQualifierValue' associated to a 'PurlQualifierKey'
findPurlQuantifiers :: PurlQualifierKey -> Purl -> Maybe PurlQualifierValue
findPurlQuantifiers k = M.lookup k . purlQualifiers

-- * Parsers

parsePurl :: Text -> Either ParseError Purl
parsePurl = runParser purlP () "parsePurl"

{- | Parses the whole 'Purl'
scheme:type/namespace/name@version?qualifiers#subpath
-}
purlP :: Parsec Text u Purl
purlP =
    Purl
        <$> purlSchemeP
        <*> purlTypeP
        <*> optionMaybe purlNamespaceP
        <*> purlNameP
        <*> optionMaybe purlVersionP
        <*> purlQualifiersP
        <*> purlSubPathP

-- | Parses the constant scheme "pkg:"
purlSchemeP :: Parsec Text u PurlScheme
purlSchemeP = Pkg <$ (string "pkg:" *> many (char '/'))

-- | Parses the package type
purlTypeP :: Parsec Text u PurlType
purlTypeP = toType <$> purlTypeStr <* char '/'
  where
    purlTypeStr = do
        first <- satisfy isAsciiLetter
        rest <- takeWhileP isTypeChar
        return $ T.toLower (first `T.cons` rest)

    isAsciiLetter c = isAscii c && isLetter c
    isTypeChar c = isAscii c && (isAlphaNum c || c `elem` (".+-" :: String))

    toType =
        \case
            "hackage" -> Hackage
            "ghc" -> Ghc
            other -> OtherType $ mk other

-- | Parses the optional namespace, stripping leading/trailing slashes and decoding segments
purlNamespaceP :: Parsec Text u PurlNamespace
purlNamespaceP = do
    raw <- many $ try $ takeWhile1P (`notElem` ("/@?#" :: String)) <* char '/'
    let decoded = mapMaybe decodeSegment raw
    guard $ not $ null decoded
    return . PurlNamespace $ decoded
  where
    decodeSegment seg
        | T.null seg = Nothing
        | '/' `elem` T.unpack (unescapeURL seg) = Nothing
        | otherwise = Just seg

-- | Parses the package name (percent-decoded)
purlNameP :: Parsec Text u PurlName
purlNameP = PurlName <$> takeWhile1P notTerminator
  where
    notTerminator c = c /= '@' && c /= '?' && c /= '#'

-- | Parses the optional version (percent-decoded), prefixed by '@'
purlVersionP :: Parsec Text u PurlVersion
purlVersionP = char '@' *> (PurlVersion <$> takeWhile1P notTerminator)
  where
    notTerminator c = c /= '?' && c /= '#'

-- | Parses the optional list of qualifiers prefixed by '?'
purlQualifiersP :: Parsec Text u (M.Map PurlQualifierKey PurlQualifierValue)
purlQualifiersP = option mempty $ char '?' *> qualifiers
  where
    qualifiers = do
        qualifierPairs <- sepBy1 qualifier (char '&')
        let keys = map fst qualifierPairs
        guard (length keys == length (nub keys))
        return $ M.fromList qualifierPairs

    qualifier = do
        key <- takeWhile1P isKeyChar
        guard (not (T.null key) && isAsciiLetter (T.head key))
        _ <- char '='
        val <- takeWhile1P (/= '&')
        guard (not (T.null val))
        return (PurlQualifierKey key, PurlQualifierValue val)

    isAsciiLetter c = isAscii c && isLetter c
    isKeyChar c = isAscii c && (isAlphaNum c || c `elem` (".-_" :: String))

-- | Parses the optional subpath (percent-decoded and normalized), prefixed by '#'
purlSubPathP :: Parsec Text u (Maybe PurlSubPath)
purlSubPathP = optionMaybe $ char '#' *> (PurlSubPath <$> segments)
  where
    segments = do
        rawSegs <- takeWhileP (/= '/') `sepBy` char '/'
        let decoded = mapMaybe decodeSegment rawSegs
        guard (not (null decoded))
        return decoded

    decodeSegment seg =
        let s = unescapeURL seg
         in if T.null s || s == "." || s == ".." || "/" `T.isInfixOf` s
                then Nothing
                else Just s

-- * Parsing helpers

takeWhileP :: (Char -> Bool) -> Parsec Text u Text
takeWhileP f = T.pack <$> many (satisfy f)

takeWhile1P :: (Char -> Bool) -> Parsec Text u Text
takeWhile1P f = T.pack <$> many1 (satisfy f)

unescapeURL :: Text -> Text
unescapeURL = TE.decodeUtf8 . urlDecode True . TE.encodeUtf8

-- * Renderers
purlText :: Purl -> Text
purlText purl =
    T.concat
        [ renderedPackage
        , renderedType
        , renderedNamespace
        , renderedName
        , renderedVersion
        , renderedQuelifiers
        , renderedSubPath
        ]
  where
    renderedPackage = "pkg:"
    renderedType =
        case purlType purl of
            Hackage -> "hackage"
            Ghc -> "ghc"
            OtherType x -> T.toCaseFold $ original x
    renderedNamespace =
        maybe "" (("/" <>) . T.intercalate "/" . unPurlNamespace) $ purlNamespace purl
    renderedName =
        "/" <> unPurlName (purlName purl)
    renderedVersion = maybe "" (("@" <>) . unPurlVersion) $ purlVersion purl
    renderedQuelifiers =
        if M.null (purlQualifiers purl)
            then ""
            else
                "?"
                    <> T.intercalate
                        "&"
                        ( map (\(PurlQualifierKey k, PurlQualifierValue v) -> k <> "=" <> v) $
                            M.toList (purlQualifiers purl)
                        )
    renderedSubPath = maybe "" (("#" <>) . T.intercalate "/" . unPurlSubPath) $ purlSubPath purl

-- * instances

instance ToJSON Purl where
    toJSON = toJSON . purlText
    toEncoding = toEncoding . purlText

instance FromJSON Purl where
    parseJSON =
        withText "Purl" $ either (fail . show) return . parsePurl