packages feed

bbcode-0.2.0.1: lib/Text/BBCode/Internal/Parser.hs

module Text.BBCode.Internal.Parser
  ( Parser
  , runParserEnv
  , runParserMaybeEnv
  , parseTestEnv
  , parsers
  , bbcode
  , bbcode1
  , hr
  , br
  , clear
  , bold
  , italic
  , underline
  , strikethrough
  , indent
  , nfo
  , oneline
  , everythingInElement
  , code
  , preformatted
  , box
  , boxAlign
  , image
  , imageAlign
  , quote
  , quoteNamed
  , spoiler
  , spoilerNamed
  , listElement
  , list
  , listFlavor
  , colorName
  , colorHex
  , url
  , size
  , align
  , font
  , plaintext
  )
where

import Control.Applicative (Applicative (liftA2))
import Control.Monad.Reader
import Data.Char (isAsciiLower, isAsciiUpper, isDigit, isSpace)
import Data.Functor (($>))
import Data.Map (Map)
import Data.Map qualified as M
import Data.Maybe (isNothing)
import Data.Text qualified as T
import Data.Text.IO qualified as T
import Data.Void (Void)
import Text.BBCode.Internal.Helper hiding (closing, opening, openingArg, surround, wrap, wrapArg)
import Text.Megaparsec
import Text.Megaparsec.Char

{-| Parser with reader transformer inside.

Reader environment contains current context for element.

It is used for 'oneline' because @"[oneline][oneline][\/oneline][\/oneline]"@ should
produce error. Same with 'nfo'

Also used by 'plaintext' to end parsing on closing element of current environment.

>>> parseTestEnv bbcode "[i]Bread[/b][/i]"
ElSimple Italic (ElText "Bread[/b]")

Notice it parsed @"[\/b]"@. 'plaintext' finishes parsing only on closing element that matches last parsed opening
-}
type Parser a = ParsecT Void Text (Reader [El]) a

-- | 'runParser' specialized for 'Parser'
runParserEnv :: ParsecT e s (Reader r) a -> r -> s -> Either (ParseErrorBundle s e) a
runParserEnv p env input = runReader (runParserT p "" input) env
{-# INLINEABLE runParserEnv #-}

-- | 'parseMaybe' specialized for 'Parser'
runParserMaybeEnv :: ParsecT e s (Reader r) a -> r -> s -> Maybe a
runParserMaybeEnv p env input =
  runParserEnv p env input & \case
    (Right x) -> Just x
    _ -> Nothing
{-# INLINEABLE runParserMaybeEnv #-}

-- | 'parseTest' specialized for 'Parser'. Passes empty list to inner reader monad
parseTestEnv :: Show a => Parser a -> Text -> IO ()
parseTestEnv p input =
  T.putStrLn
    . fromString
    . either errorBundlePretty show
    $ runParserEnv p [] input
{-# INLINEABLE parseTestEnv #-}

-- | Same as 'parseTestEnv' but works with HLS's codelenses
mp :: Show a => Parser a -> Text -> IO String
mp parser text = error . either errorBundlePretty show $ runParserEnv parser [] text

opening :: El -> Parser ()
opening el = void . string' $ mconcat ["[", elName el, "]"]
{-# INLINEABLE opening #-}

openingArg :: El -> Parser a -> Parser a
openingArg el p = do
  void . try . string' $ mconcat ["[", elName el, "="]
  res <- p
  void . char $ ']'
  pure res
{-# INLINEABLE openingArg #-}

closing :: El -> Parser ()
closing el = void . string' $ mconcat ["[/", elName el, "]"]
{-# INLINEABLE closing #-}

localAddEl :: MonadReader [r] m => r -> m a -> m a
localAddEl el = local (<> [el])
{-# INLINEABLE localAddEl #-}

elVoid :: El -> Parser BBCode
elVoid el = opening el $> ElVoid el
{-# INLINEABLE elVoid #-}

elSimple :: El -> Parser BBCode -> Parser BBCode
elSimple el body = do
  opening el
  res <- localAddEl el body
  closing el
  pure $ ElSimple el res
{-# INLINEABLE elSimple #-}

elArg :: El -> Parser Text -> Parser BBCode -> Parser BBCode
elArg el arg body = do
  a <- openingArg el arg
  b <- localAddEl el body
  closing el
  pure $ ElArg el a b
{-# INLINEABLE elArg #-}

{-|All parsers used by 'bbcode' and 'bbcode1'

Keys are element names and values are associated element parsers.

Values are lists because some elements have optional element.
That means they are two separate parsers, e.g.
'Box' element parsers are 'box' (simple element) and 'boxAlign' (element with
parameter)
-}
parsers :: Map El [Parser BBCode]
parsers =
  M.fromList
    [ (HR, [hr])
    , (BR, [br])
    , (Clear, [clear])
    , (ListElement, [listElement])
    , (Bold, [bold])
    , (Italic, [italic])
    , (Underline, [underline])
    , (Strikethrough, [strikethrough])
    , (Indent, [indent])
    , (NFO, [nfo])
    , (Oneline, [oneline])
    , (Code, [code])
    , (Preformatted, [preformatted])
    , (Box, [box, boxAlign])
    , (Image, [image, imageAlign])
    , (Quote, [quote, quoteNamed])
    , (Spoiler, [spoiler, spoilerNamed])
    , (List, [list, listFlavor])
    , (Color, [colorName, colorHex])
    , (Size, [size])
    , (URL, [url])
    , (Align, [align])
    , (Font, [font])
    ]
{-# NOINLINE parsers #-}

{-| Parse zero or more BBCode elements
Doesn't necessarily return value wrapped in 'ElDocument', it returns
@('mempty' :: BBCode)@ if it parses no elements, or just element if parses
just one element. Otherwise it is 'ElDocument'
-}
bbcode :: Parser BBCode
bbcode = do
  many (choice $ concat parsers <> [plaintext])
    <&> \case
      [] -> mempty
      [x] -> x
      xs -> ElDocument xs
{-# NOINLINE bbcode #-}

-- | Similar to 'bbcode' but parses one or more elements
bbcode1 :: Parser BBCode
bbcode1 =
  some (choice $ concat parsers)
    <&> \case
      [x] -> x
      xs -> ElDocument xs
{-# NOINLINE bbcode1 #-}

hr :: Parser BBCode
hr = elVoid HR
{-# INLINEABLE hr #-}

br :: Parser BBCode
br = elVoid BR
{-# INLINEABLE br #-}

clear :: Parser BBCode
clear = elVoid Clear
{-# INLINEABLE clear #-}

bold :: Parser BBCode
bold = elSimple Bold bbcode

italic :: Parser BBCode
italic = elSimple Italic bbcode

underline :: Parser BBCode
underline = elSimple Underline bbcode

strikethrough :: Parser BBCode
strikethrough = elSimple Strikethrough bbcode

indent :: Parser BBCode
indent = elSimple Indent bbcode

nfo :: Parser BBCode
nfo = do
  insideNFO <- preview (folded . filtered (== NFO))
  if isNothing insideNFO
    then elSimple NFO bbcode
    else fail "Can't parse [nfo] inside [nfo]"

oneline :: Parser BBCode
oneline = do
  insideOneline <- preview (folded . filtered (== Oneline))
  if isNothing insideOneline
    then elSimple Oneline bbcode
    else fail "Can't parse [oneline] inside [oneline]"

-- BUG: in a string "[code][code][/code][/code]" parser only does
--
-- >>> mp (everythingInElement Code) "[code][code][/code][/code]"
-- ElSimple Code (ElText "[code]")
-- >>> mp (everythingInElement Code <* eof) "[code][code][/code][/code]"
-- 1:20:
--   |
-- 1 | [code][code][/code][/code]
--   |                    ^
-- unexpected '['
-- expecting end of input
everythingInElement :: El -> Parser BBCode
everythingInElement el = elSimple el $ do
  let bc = do
        r <- takeWhileP Nothing (/= '[')
        end <- lookAhead . optional $ closing el
        if isNothing end
          then (r <>) <$> liftA2 (<>) (takeP Nothing 1) bc
          else pure r
  ElText <$> bc
{-# INLINEABLE everythingInElement #-}

-- >>> mp (code <* eof) "[code][code][/code][/code]"
code :: Parser BBCode
code = everythingInElement Code
{-# INLINEABLE code #-}

preformatted :: Parser BBCode
preformatted = everythingInElement Preformatted
{-# INLINEABLE preformatted #-}

box :: Parser BBCode
box = elSimple Box bbcode

image :: Parser BBCode
image = elSimple Image $ ElText <$> takeWhile1P (Just "URL") (/= '[')
{-# INLINEABLE image #-}

quote :: Parser BBCode
quote = elSimple Quote bbcode

spoiler :: Parser BBCode
spoiler = elSimple Spoiler bbcode

listElement :: Parser BBCode
listElement = do
  isInsideList <- has (_Just . _List) <$> preview _last
  unless isInsideList $ fail "Not inside list"
  res <- elVoid ListElement
  void $ takeWhileP Nothing isSpace
  pure res

list :: Parser BBCode
list = elSimple List bbcode

boxAlign :: Parser BBCode
boxAlign = elArg Box (string "left" <|> string "center" <|> string "right") bbcode

imageAlign :: Parser BBCode
imageAlign =
  elArg Image (string "left" <|> string "right") $
    ElText <$> takeWhile1P (Just "URL") (/= '[')
{-# INLINEABLE imageAlign #-}

{-| Can give cryptic error message if quote is ill-formed.

>>>> parseTestEnv quoteNamed "[quote=\"QQ\"\"a][/quote]"
1:24:
  |
1 | [quote="QQ""a][/quote]
  |                       ^
unexpected end of input
expecting quote author in quotes(e.g. [quote="Dan"])

Issue here is letter \'a\' before closing bracket
-}
quoteNamed :: Parser BBCode
quoteNamed = elArg Quote arg bbcode
  where
    go :: Parser Text = do
      text <- takeWhileP (Just "\"]") (/= '"')
      end <- lookAhead . optional $ string "\"]"
      if isNothing end
        then (text <>) <$> liftA2 (<>) (hidden $ string "\"") go
        else pure text
    arg =
      label "quote author in quotes(e.g. [quote=\"Dan\"])" $
        try $
          char '"' *> go <* string "\"]"

-- | See 'quoteNamed'
spoilerNamed :: Parser BBCode
spoilerNamed = elArg Spoiler arg bbcode
  where
    go :: Parser Text = do
      text <- takeWhileP (Just "\"]") (/= '"')
      end <- lookAhead . optional $ string "\"]"
      if isNothing end
        then (text <>) <$> liftA2 (<>) (hidden $ string "\"") go
        else pure text
    arg =
      label "spoiler name in quotes (e.g. [spoiler=\"The ending is\"])" $
        try $
          char '"' *> go <* string "\"]"

listFlavor :: Parser BBCode
listFlavor =
  elArg
    List
    (choice $ string <$> ["a", "A", "i", "I", "1"])
    bbcode

-- >>> mp colorName "[color=gray][/color]"
-- ElArg Color "GreEn" (ElText "")
colorName :: Parser BBCode
colorName =
  elArg
    Color
    ( takeWhile1P
        (Just "Color name")
        (\c -> isAsciiLower c || isAsciiUpper c || isDigit c)
    )
    bbcode

-- >>> mp colorHex "[color=#333][/color]"
-- ElArg Color "#333" (ElText "")
-- >>> mp colorHex "[color=#333Faa][/color]"
-- ElArg Color "#333Faa" (ElText "")
colorHex :: Parser BBCode
colorHex =
  elArg
    Color
    ( label "hex color(e.g. #333 or #123456)" $ do
        hash :: Char <- char '#'
        name :: String <-
          try (count 6 hexDigitChar)
            <|> count 3 hexDigitChar
        pure . T.pack $ hash : name
    )
    bbcode

url :: Parser BBCode
url = elArg URL (takeWhileP (Just "URL") (/= ']')) bbcode

size :: Parser BBCode
size =
  elArg
    Size
    (choice $ string . fromString . show <$> ([10 .. 29] :: [Int]))
    bbcode

align :: Parser BBCode
align =
  elArg
    Align
    (choice $ string <$> ["left", "right", "center", "justify"])
    bbcode

font :: Parser BBCode
font =
  elArg
    Font
    ( takeWhile1P
        (Just "font name(digits and latin letters)")
        (isAsciiLower ||| isAsciiUpper ||| isDigit)
    )
    bbcode

plaintext :: Parser BBCode
plaintext = do
  currentElement <- preview _last
  let bc = do
        res <- takeWhile1P Nothing (/= '[')
        end <- -- Attempts to find element that will end parsing
          lookAhead . optional . choice . concat $
            [ -- Opening element ends parsing text(only simple elements)
              elementOpenings
                ^.. folded
                  . to elName
                  . to (\el -> mconcat ["[", el, "]"])
                  . to string'
            , -- Opening element ends parsing text.
              -- Note that it produces opening element without closing bracket,
              -- because we can't predict what an argument will be
              elementArgOpenings
                ^.. folded
                  . to elName
                  . to ("[" <>)
                  . to string'
            , -- Current closing element (last element in reader environment)
              currentElement
                ^.. _Just
                  . to closing
                  . to ($> "")
            , [eof $> ""]
            ]
        if isNothing end
          then -- continue parsing text
            (res <>) <$> liftA2 (<>) (takeP Nothing 1) bc
          else -- end parsing
            pure res
  ElText <$> bc
{-# INLINEABLE plaintext #-}