packages feed

nirum-0.5.0: src/Nirum/Package/Metadata.hs

{-# LANGUAGE GADTs, LambdaCase, QuasiQuotes, RankNTypes, ScopedTypeVariables,
             StandaloneDeriving, TypeFamilies #-}
module Nirum.Package.Metadata ( Author (Author, email, name, uri)
                              , Metadata ( Metadata
                                         , authors
                                         , description
                                         , keywords
                                         , license
                                         , target
                                         , version
                                         )
                              , MetadataError ( FieldError
                                              , FieldTypeError
                                              , FieldValueError
                                              , FormatError
                                              )
                              , MetadataField
                              , MetadataFieldType
                              , Node ( VArray
                                     , VBoolean
                                     , VDatetime
                                     , VFloat
                                     , VInteger
                                     , VString
                                     , VTable
                                     , VTArray
                                     )
                              , Package (..)
                              , Table
                              , Target ( CompileError
                                       , CompileResult
                                       , compilePackage
                                       , parseTarget
                                       , showCompileError
                                       , targetName
                                       , toByteString
                                       )
                              , TargetName
                              , VTArray
                              , fieldType
                              , metadataFilename
                              , metadataPath
                              , optional
                              , parseMetadata
                              , packageTarget
                              , prependMetadataErrorField
                              , readFromPackage
                              , readMetadata
                              , stringField
                              , tableField
                              , tableArrayField
                              , textArrayField
                              , versionField
                              ) where

import Data.Proxy (Proxy (Proxy))
import Data.Typeable (Typeable)
import GHC.Exts (IsList (fromList, toList))

import Data.ByteString (ByteString)
import qualified Data.HashMap.Strict as HM
import Data.Map.Strict (Map)
import qualified Data.SemVer as SV
import Data.Text (Text, append, snoc, unpack)
import Data.Text.Encoding (encodeUtf8)
import qualified Data.Text.IO as TIO
import System.FilePath ((</>))
import System.IO
import Text.Email.Parser (EmailAddress)
import qualified Text.Email.Validate as EV
import Text.InterpolatedString.Perl6 (qq)
import Text.Parsec.Error (ParseError)
import Text.Toml (parseTomlDoc)
import Text.Toml.Types (Node ( VArray
                             , VBoolean
                             , VDatetime
                             , VFloat
                             , VInteger
                             , VString
                             , VTable
                             , VTArray
                             )
                       , Table
                       , VTArray
                       , VArray
                       )
import Text.URI (URI, parseURI)

import Nirum.Constructs.Docs (Docs)
import Nirum.Package.ModuleSet (ModuleSet)

-- | The filename of Nirum package metadata.
metadataFilename :: FilePath
metadataFilename = "package.toml"

-- | Represents a package which consists of modules.
data Package t = Package
    { metadata :: (Eq t, Ord t, Show t, Target t) => Metadata t
    , modules :: ModuleSet
    , documents :: Map FilePath Docs
    }

deriving instance (Eq t, Target t) => Eq (Package t)
deriving instance (Ord t, Target t) => Ord (Package t)
deriving instance (Show t, Target t) => Show (Package t)

packageTarget :: Target t => Package t -> t
packageTarget Package { metadata = Metadata { target = t } } = t

data Metadata t =
    Metadata { version :: SV.Version
             , description :: Maybe Text
             , license :: Maybe Text
             , keywords :: [Text]
             , authors :: [Author]
             , target :: (Eq t, Ord t, Show t, Target t) => t
             }
-- TODO: uri, dependencies

deriving instance (Eq t, Target t) => Eq (Metadata t)
deriving instance (Ord t, Target t) => Ord (Metadata t)
deriving instance (Show t, Target t) => Show (Metadata t)

data Author = Author { name :: Text
                     , email :: Maybe EmailAddress
                     , uri :: Maybe URI
                     } deriving (Eq, Ord, Show)

type TargetName = Text

class (Eq t, Ord t, Show t, Typeable t) => Target t where
    type family CompileResult t :: *
    type family CompileError t :: *

    -- | The name of the given target e.g. @"python"@.
    targetName :: Proxy t -> TargetName

    -- | Parse the target metadata.
    parseTarget :: Table -> Either MetadataError t

    -- | Compile the package to a source tree of the target.
    compilePackage :: Package t
                   -> Map FilePath (Either (CompileError t) (CompileResult t))

    -- | Show a human-readable message from the given 'CompileError'.
    showCompileError :: t -> CompileError t -> Text

    -- | Encode the given 'CompileResult' to a 'ByteString'
    toByteString :: t -> CompileResult t -> ByteString

-- | Name of package.toml field.
type MetadataField = Text

-- | Typename of package.toml field e.g. @"string"@, @"array of 3 values"@.
type MetadataFieldType = Text

-- | Error related to parsing package.toml.
data MetadataError
    -- | A required field is missing.
    = FieldError MetadataField
    -- | A field has a value of incorrect type e.g. array for @version@ field.
    | FieldTypeError MetadataField MetadataFieldType MetadataFieldType
    -- | A field has a value of invalid format
    -- e.g. @"1/2/3"@ for @version@ field.
    | FieldValueError MetadataField String
    -- | The given package.toml file is not a valid TOML.
    | FormatError ParseError
    deriving (Eq, Show)

-- | Prepend the given prefix to a 'MetadataError' value's field information.
-- Note that a period is automatically inserted right after the given prefix.
-- It's useful for handling of accessing nested tables.
prependMetadataErrorField :: MetadataField -> MetadataError -> MetadataError
prependMetadataErrorField prefix e =
    case e of
        FieldError f -> FieldError $ prepend f
        FieldTypeError f e' a -> FieldTypeError (prepend f) e' a
        FieldValueError f m -> FieldValueError (prepend f) m
        e'@(FormatError _) -> e'
  where
    prepend :: MetadataField -> MetadataField
    prepend = (prefix `snoc` '.' `append`)

parseMetadata :: forall t . Target t
              => FilePath -> Text -> Either MetadataError (Metadata t)
parseMetadata metadataPath' tomlText = do
    table <- case parseTomlDoc metadataPath' tomlText of
        Left e -> Left $ FormatError e
        Right t -> Right t
    -- NOTE: When a new field is added please write docs about it in
    -- docs/package.md file.
    version' <- versionField "version" table
    authors' <- authorsField "authors" table
    description' <- optional $ stringField "description" table
    license' <- optional $ stringField "license" table
    keywords' <- textArrayField "keywords" table
    targets <- case tableField "targets" table of
        Left (FieldError _) -> Right HM.empty
        otherwise' -> otherwise'
    targetTable <- case tableField targetName' targets of
        Left (FieldError _) -> Right HM.empty
        Left e -> Left $ prependMetadataErrorField "targets" e
        otherwise' -> otherwise'
    target' <- case parseTarget targetTable of
        Left e -> Left $ prependMetadataErrorField "targets"
                       $ prependMetadataErrorField targetName' e
        otherwise' -> otherwise'
    return Metadata { version = version'
                    , description = description'
                    , license = license'
                    , keywords = keywords'
                    , authors = authors'
                    , target = target'
                    }
  where
    targetName' :: Text
    targetName' = targetName (Proxy :: Proxy t)

readMetadata :: Target t => FilePath -> IO (Either MetadataError (Metadata t))
readMetadata metadataPath' = do
    h <- openFile metadataPath' ReadMode
    hSetEncoding h utf8
    tomlText <- TIO.hGetContents h
    return $ parseMetadata metadataPath' tomlText

metadataPath :: FilePath -> FilePath
metadataPath = (</> metadataFilename)

readFromPackage :: Target t
                => FilePath -> IO (Either MetadataError (Metadata t))
readFromPackage = readMetadata . metadataPath

-- | Show the typename of the given 'Node'.
fieldType :: Node -> MetadataFieldType
fieldType (VTable t) = if length t == 1
                       then "table of an item"
                       else [qq|table of {length t} items|]
fieldType (VTArray a) = [qq|array of {length a} tables|]
fieldType (VString s) = [qq|string ($s)|]
fieldType (VInteger i) = [qq|integer ($i)|]
fieldType (VFloat f) = [qq|float ($f)|]
fieldType (VBoolean True) = "boolean (true)"
fieldType (VBoolean False) = "boolean (false)"
fieldType (VDatetime d) = [qq|datetime ($d)|]
fieldType (VArray a) = if length a == 1
                       then "array of a value"
                       else [qq|array of {length a} values|]

field :: MetadataField -> Table -> Either MetadataError Node
field field' table =
    case HM.lookup field' table of
        Just node -> return node
        Nothing -> Left $ FieldError field'

typedField :: MetadataFieldType
           -> (Node -> Maybe v)
           -> MetadataField
           -> Table
           -> Either MetadataError v
typedField typename match field' table = do
    node <- field field' table
    case match node of
        Just value -> return value
        Nothing -> Left $ FieldTypeError field' typename $ fieldType node

optional :: Either MetadataError a -> Either MetadataError (Maybe a)
optional (Right value) = Right $ Just value
optional (Left (FieldError _)) = Right Nothing
optional (Left error') = Left error'

tableField :: MetadataField -> Table -> Either MetadataError Table
tableField = typedField "table" $ \ case
    VTable t -> Just t
    _ -> Nothing

stringField :: MetadataField -> Table -> Either MetadataError Text
stringField = typedField "string" $ \ case
    VString s -> Just s
    _ -> Nothing

arrayField :: MetadataField -> Table -> Either MetadataError VArray
arrayField f t =
    case arrayF f t of
        Right vector -> Right vector
        Left (FieldError _) -> Right $ fromList []
        Left error' -> Left error'
  where
    arrayF :: MetadataField -> Table -> Either MetadataError VArray
    arrayF = typedField "array" $ \ case
        VArray array -> Just array
        _ -> Nothing


tableArrayField :: MetadataField -> Table -> Either MetadataError VTArray
tableArrayField f t =
    case arrayF f t of
        Right vector -> Right vector
        Left (FieldError _) -> Right $ fromList []
        Left error' -> Left error'
  where
    arrayF :: MetadataField -> Table -> Either MetadataError VTArray
    arrayF = typedField "array of tables" $ \ case
        VTArray array -> Just array
        _ -> Nothing

uriField :: MetadataField -> Table -> Either MetadataError URI
uriField field' table = do
    s <- stringField field' table
    case parseURI (unpack s) of
        Just uri' -> Right uri'
        Nothing -> Left $ FieldValueError field'
                                          [qq|expected a URI string, not $s|]

emailField :: MetadataField -> Table -> Either MetadataError EmailAddress
emailField field' table = do
    s <- stringField field' table
    case EV.validate (encodeUtf8 s) of
        Right emailAddress -> Right emailAddress
        Left e -> Left $
            FieldValueError field' [qq|expected an email address, not $s; $e|]

versionField :: MetadataField -> Table -> Either MetadataError SV.Version
versionField field' table = do
    s <- stringField field' table
    case SV.fromText s of
        Right v -> return v
        Left _ -> Left $ FieldValueError field' $
                    "expected a semver string (e.g. \"1.2.3\"), not " ++ show s

textArrayField :: MetadataField -> Table -> Either MetadataError [Text]
textArrayField field' table = do
    array <- arrayField field' table
    textArray' <- mapM parseText array
    return $ toList textArray'
  where
    parseText :: Node -> Either MetadataError Text
    parseText (VString s) = Right s
    parseText a = Left $ FieldTypeError field' "array" $ fieldType a

authorsField :: MetadataField -> Table -> Either MetadataError [Author]
authorsField field' table = do
    array <- tableArrayField field' table
    authors' <- mapM parseAuthor array
    return $ toList authors'
  where
    parseAuthor :: Table -> Either MetadataError Author
    parseAuthor t = do
        name' <- stringField "name" t
        email' <- optional $ emailField "email" t
        uri' <- optional $ uriField "uri" t
        return Author { name = name'
                      , email = email'
                      , uri = uri'
                      }