prosidy-1.6.0.2: src/Prosidy/Parse.hs
{- |
Module : Prosidy.Parse
Description : Parse raw text into Prosidy documents
Copyright : (c) James Alexander Feldman-Crough, 2019
License : MPL-2.0
Maintainer : alex@fldcr.com
-}
{-# LANGUAGE Trustworthy #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE ApplicativeDo #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE NoImplicitPrelude #-}
module Prosidy.Parse
( -- * Parsing Prosidy types from 'Data.Text.Text'
parseDocument
, parseDocumentMetadata
-- * Reading & parsing Prosidy files
, readDocument
, readDocumentMetadata
-- * Errors
, Failure(..)
, prettyFailure
)
where
import Control.Monad.Fail.Compat ( MonadFail(..) )
import Prelude hiding ( fail )
import Prosidy.Types
import Prosidy.Source
import Prosidy.Types.Key ( isValidKeyHead
, isValidKeyTail
, unsafeMakeKey
)
import Prosidy.Types.Series ( fromSeqNE
, toSeqNE
, fromSeq
)
import Text.Megaparsec hiding ( token
, sourceName
)
import Text.Megaparsec.Char ( char
, string
)
import qualified Data.Char as Char
import qualified Data.Text.Lazy as Text.Lazy
import qualified Data.HashMap.Strict as HashMap
import qualified Data.Sequence as Seq
import qualified Data.List as List
import qualified Data.HashSet as HashSet
import qualified Data.Text.Encoding as Text.Encoding
import qualified Text.Megaparsec.Char as Megaparsec
import qualified Data.ByteString as ByteString
import Control.Applicative ( Alternative )
import Data.Bifunctor ( first )
import Text.Megaparsec.Char.Lexer ( hexadecimal )
import Data.Functor ( ($>) )
import Data.Foldable ( fold
, traverse_
)
import Control.Monad ( MonadPlus
, void
)
import Data.Text ( Text )
import Data.Void ( Void )
import Control.Exception ( Exception
, throwIO
)
import Control.Monad.Trans.Reader ( ReaderT(..) )
{-
If you run into errors, use the following combinator to get Megaparsec to
print out its state.
> import qualified Text.Megaparsec.Debug
>
> dbg :: Show a => String -> P a -> P a
> dbg txt (P (ReaderT r)) = P . ReaderT $ \src ->
> Text.Megaparsec.Debug.dbg txt $ r src
-}
-------------------------------------------------------------------------------
-- | Parses a Prosidy 'Document' from its source.
--
-- The 'FilePath' parameter is only used for error reporting.
parseDocument :: FilePath -> Text -> Either Failure Document
parseDocument path = runP doc . makeSource path
-- | Reads a Prosidy 'Document' from the given 'FilePath'.
--
-- Errors will be thrown as exceptions. Use 'parseDocument' for a pure
-- implementation.
readDocument :: FilePath -> IO Document
readDocument filepath = do
bytes <- ByteString.readFile filepath
either throwIO pure . parseDocument filepath $ Text.Encoding.decodeUtf8With
(\_ _ -> Just '\65533')
bytes
-------------------------------------------------------------------------------
-- | Parses a Prosidy document's header 'Metadata' from source, stopping when the
-- header ends.
--
-- The 'FilePath' parameter is only used for error reporting.
parseDocumentMetadata :: FilePath -> Text -> Either Failure Metadata
parseDocumentMetadata path = runP docMetadata . makeSource path
-- | Reads a Prosidy document's 'Metadata' header from the given 'FilePath'.
--
-- Errors will be thrown as exceptions. Use 'parseDocumentMetadata' for a pure
-- implementation.
readDocumentMetadata :: FilePath -> IO Metadata
readDocumentMetadata filepath = do
bytes <- ByteString.readFile filepath
either throwIO pure
. parseDocumentMetadata filepath
$ Text.Encoding.decodeUtf8 bytes
-------------------------------------------------------------------------------
-- | A parsing error.
--
newtype Failure = Failure (ParseErrorBundle Text Void)
deriving newtype (Exception, Show)
-- | Pretty-print a 'Failure' into a message acceptable for displaying to
-- users.
prettyFailure :: Failure -> String
prettyFailure (Failure e) = errorBundlePretty e
-------------------------------------------------------------------------------
newtype P a = P (ReaderT Source (Parsec Void Text) a)
deriving newtype (Functor, Applicative, Alternative, Monad, MonadFail, MonadPlus, MonadParsec Void Text)
type MetadataItem = (Key, Maybe Text)
runP :: P a -> Source -> Either Failure a
runP (P (ReaderT r)) src =
first Failure $ parse (r src) (sourceName src) (sourceText src)
-------------------------------------------------------------------------------
doc :: P Document
doc = do
header <- docMetadata
body <- Series . Seq.fromList <$> many block
eof
pure $ Document header body
-------------------------------------------------------------------------------
docMetadata :: P Metadata
docMetadata = do
-- try to read a shebang as the _very_ first line of a document.
optional_ $ string "#!" *> skipManyTill anySingle (void newlineOrEOF)
-- skip past any blank lines at the start of the document
skipSpaces *> skipMany endOfLine
-- read each metadata item as a line-wide token
items <- many docMetadataItem
-- stop when we hit three dashes, alone, on a line
docMetadataEnd
pure $ foldMap itemToMetadata items
docMetadataEnd :: P ()
docMetadataEnd = do
void $ string "---"
try $ do
skipSpaces
newlineOrEOF
skipSpaces
skipMany endOfLine
docMetadataItem :: P MetadataItem
docMetadataItem = do
itemKey <- key
itemVal <- optional $ do
metaItemSep
option "" text <* skipSpaces
endOfLines
pure (itemKey, itemVal)
-------------------------------------------------------------------------------
block :: P Block
block = choice
[ BlockTag <$> blockTag
, BlockLiteral <$> literalTag
, BlockParagraph <$> paragraph
]
blockTag :: P BlockTag
blockTag = do
t <- genericTag (void $ string "#-") blockTagContents
emptyLines
pure t
blockTagContents :: P (Series Block)
blockTagContents = choice [ifBraces, ifBlock, ifNothing]
where
ifBraces = annotateSource $ fmap
(foldMap $ \x src ->
Series . Seq.singleton . BlockParagraph $ Paragraph x src
)
(token tagParagraph)
ifBlock = Series . Seq.fromList <$> withBlockDelimiters
(emptyLines *> many block)
ifNothing = skipSpaces *> endOfLine $> mempty
literalTag :: P LiteralTag
literalTag = genericTag (void $ string "#=") $ do
close <- blockTagDelim (void $ optional_ comment *> newlineOrEOF)
literalBody close
literalBody :: P () -> P Text
literalBody end = do
literalLines <- manyTill literalLine (try $ skipSpaces *> end)
emptyLines
pure $ Text.Lazy.toStrict . Text.Lazy.intercalate "\n" $ literalLines
literalLine :: P Text.Lazy.Text
literalLine = do
line <- takeWhileP (Just "literal text") $ \ch -> ch /= '\r' && ch /= '\n'
newlineOrEOF
pure $ Text.Lazy.fromStrict line
blockTagDelim :: P () -> P (P ())
blockTagDelim slurp = do
char ':'
maybeLabel <- optional keyLike
skipSpaces <* slurp
pure $ do
string "#:"
traverse_ string maybeLabel
skipSpaces
withBlockDelimiters :: P a -> P a
withBlockDelimiters parser = do
close <- blockTagDelim endOfLine
parser <* close
-------------------------------------------------------------------------------
inline :: P Inline
inline = choice [InlineTag <$> inlineTag, InlineText <$> fragment]
inlineTag :: P InlineTag
inlineTag = genericTag sigil . option mempty $ orEmpty tagParagraph
where
orEmpty = fmap $ maybe mempty (fromSeq . toSeqNE)
sigil = try $ do
void $ char '#'
void . lookAhead $ satisfy isValidKeyHead
-------------------------------------------------------------------------------
paragraph :: P Paragraph
paragraph = annotateSource $ paragraphLike >>= maybe
(fail "empty paragraph encountered")
(pure . Paragraph)
paragraphLike :: P (Maybe (SeriesNE Inline))
paragraphLike = do
ppLines <- paragraphLine `sepEndBy1` endOfLine
emptyLines
pure . fromSeqNE . Seq.fromList $ List.intercalate [Break] ppLines
paragraphLine :: P [Inline]
paragraphLine = do
headItem <- inline
tailItem <- many paragraphInline
skipSpaces
pure $ headItem : tailItem
paragraphInline :: P Inline
paragraphInline = (paragraphSpacer $> Break) <|> inline
paragraphSpacer :: P ()
paragraphSpacer = try $ do
skipSpaces1
notFollowedBy $ void (string "##") <|> newlineOrEOF
tagParagraph :: P (Maybe (SeriesNE Inline))
tagParagraph = between start end $ option Nothing paragraphLike
where
start = char '{' *> skipSpaces *> emptyLines
end = skipSpaces *> emptyLines *> char '}'
-------------------------------------------------------------------------------
genericTag :: P () -> P a -> P (Tag a)
genericTag sigilParser bodyParser = annotateSource $ do
sigilParser
thisName <- unsafeMakeKey <$> keyLike
thisMetadata <- meta
thisContent <- bodyParser
pure $ Tag thisName thisMetadata thisContent
meta :: P Metadata
meta =
option mempty
$ between start end
$ foldMap itemToMetadata
<$> metaItem
`sepEndBy` metaSep
where
start = do
char '['
skipSpaces
skipMany endOfLine
end = char ']'
metaItem :: P MetadataItem
metaItem = do
itemKey <- key <* emptyLines
itemVal <- optional $ do
metaItemSep <* emptyLines
option "" quotedText
skipSpaces <* emptyLines
pure (itemKey, itemVal)
metaSep :: P ()
metaSep = do
void $ char ','
skipSpaces
emptyLines
-------------------------------------------------------------------------------
escape :: P Char
escape = label "escape sequence" $ do
void $ char '\\'
choice
[ oneOf @[] "#{}[]:='\"\\"
, char 'n' $> '\n'
, char 't' $> '\t'
, char 'r' $> '\r'
, char 'u' *> unicodeEscape
]
unicodeEscape :: P Char
unicodeEscape = Char.chr <$> hexadecimal
keyLike :: P Text
keyLike = do
void . lookAhead $ satisfy isValidKeyHead
takeWhile1P (Just "key") isValidKeyTail
key :: P Key
key = token $ unsafeMakeKey <$> keyLike
metaItemSep :: P ()
metaItemSep = token . void $ char ':' <|> char '='
quotedText :: P Text
quotedText = do
delim <- char '\'' <|> char '"'
parts <- many $ choice
[ Text.Lazy.singleton <$> escape
, Text.Lazy.fromStrict <$> takeWhile1P
(Just "quoted text")
(\ch -> ch /= delim && ch /= '\\')
]
void $ char delim
skipSpaces
pure . Text.Lazy.toStrict . fold $ parts
fragment :: P Fragment
fragment = annotateSource $ Fragment <$> text
text :: P Text
text = do
parts <- word `sepBy1` textSpace
pure . Text.Lazy.toStrict . Text.Lazy.intercalate " " $ parts
textSpace :: P ()
textSpace = try $ do
skipSpaces1
notFollowedBy $ void (char '#') <|> newlineOrEOF
word :: P Text.Lazy.Text
word = fmap fold . some $ choice
[ Text.Lazy.singleton <$> escape
, Text.Lazy.fromStrict <$> takeWhile1P
(Just "plain text")
(\ch -> not $ HashSet.member ch reserved || Char.isSpace ch)
]
where reserved = HashSet.fromList "#{}\\"
-------------------------------------------------------------------------------
comment :: P ()
comment = label "comment" $ do
void $ string "##"
void $ skipManyTill anySingle (lookAhead newlineOrEOF)
endOfLine :: P ()
endOfLine =
-- This rule is a bit hairy! Specifically, there was a bug at the end of
-- a file that ended with a comment and no trailing newline.
--
-- Because we use `endOfLine` in repeat productions (many, some),
-- endOfLine _has_ to consume input to prevent looping forever.
-- In order to satisfy this:
--
-- 1. If its the end of a file, then we _must_ consume a comment.
-- 2. If it's not the end of a file, then we _must_ consume at least
-- one newline.
commentThenNewline <|> commentThenEOF
where
commentThenEOF = comment <* eof
commentThenNewline = try $ do
optional_ comment
void Megaparsec.newline
skipSpaces
endOfLines :: P ()
endOfLines = skipSome endOfLine
emptyLines :: P ()
emptyLines = skipMany endOfLine
spaceChar :: P ()
spaceChar = do
notFollowedBy newlineOrEOF
void Megaparsec.spaceChar
skipSpaces :: P ()
skipSpaces = skipMany spaceChar
skipSpaces1 :: P ()
skipSpaces1 = skipSome spaceChar
token :: P a -> P a
token = (<* skipSpaces)
newlineOrEOF :: P ()
newlineOrEOF = void Megaparsec.newline <|> eof
-------------------------------------------------------------------------------
optional_ :: P a -> P ()
optional_ = option () . void
-------------------------------------------------------------------------------
itemToMetadata :: MetadataItem -> Metadata
itemToMetadata (k, Just v ) = Metadata mempty (Assoc $ HashMap.singleton k v)
itemToMetadata (k, Nothing) = Metadata (Set $ HashSet.singleton k) mempty
annotateSource :: P (Maybe Location -> a) -> P a
annotateSource (P (ReaderT r)) = P . ReaderT $ \src -> do
offset <- Offset . fromIntegral <$> getOffset
result <- r src
sourceLoc <- maybe (fail sourceLocationError) pure $ getLocation offset src
pure . result $ Just sourceLoc
sourceLocationError :: String
sourceLocationError = "UNEXPECTED: Failed to create a source location."