{-|
Module      : Text.Jira.Parser.Core
Copyright   : © 2019 Albert Krewinkel
License     : MIT

Maintainer  : Albert Krewinkel <tarleb@zeitkraut.de>
Stability   : alpha
Portability : portable

Core components of the Jira wiki markup parser.
-}
module Text.Jira.Parser.Core
  (
  -- * Jira parser and state
    JiraParser
  , ParserState (..)
  , defaultState
  , parseJira
  , withStateFlag
  -- * String position tracking
  , updateLastStrPos
  , notAfterString
  -- * Parsing helpers
  , endOfPara
  , notFollowedBy'
  , blankline
  , skipSpaces
  , blockNames
  ) where

import Control.Monad (join, void)
import Data.Text (Text)
import Text.Parsec

-- | Jira Parsec parser
type JiraParser = Parsec Text ParserState

-- | Parser state used to keep track of various parameteres.
data ParserState = ParserState
  { stateInLink      :: Bool            -- ^ whether the parser is within a link
  , stateInList      :: Bool            -- ^ whether the parser is within a list
  , stateInTable     :: Bool            -- ^ whether the parser is within a table
  , stateLastStrPos  :: Maybe SourcePos -- ^ position at which the last string
                                        --   ended
  }

-- | Default parser state (i.e., start state)
defaultState :: ParserState
defaultState = ParserState
  { stateInLink      = False
  , stateInList      = False
  , stateInTable     = False
  , stateLastStrPos  = Nothing
  }

-- | Set a flag in the parser to @True@ before running a parser, then
-- set the flag's value to @False@.
withStateFlag :: (Bool -> ParserState -> ParserState)
              -> JiraParser a
              -> JiraParser a
withStateFlag flagSetter parser = try $
  let setFlag = modifyState . flagSetter
  in setFlag True *> parser <* setFlag False

-- | Updates the state, marking the current input position as the end of a
-- string.
updateLastStrPos :: JiraParser ()
updateLastStrPos = do
  pos <- getPosition
  modifyState $ \st -> st { stateLastStrPos = Just pos }

-- | Checks whether the parser is directly after a string.
notAfterString :: JiraParser Bool
notAfterString = do
  curPos <- getPosition
  prevPos <- stateLastStrPos <$> getState
  return (Just curPos /= prevPos)

-- | Parses a string with the given Jira parser.
parseJira :: JiraParser a -> Text -> Either ParseError a
parseJira parser = runParser parser defaultState ""

-- | Skip zero or more space chars.
skipSpaces :: JiraParser ()
skipSpaces = skipMany (char ' ')

-- | Parses an empty line, i.e., a line with no chars or whitespace only.
blankline :: JiraParser ()
blankline = try $ skipSpaces *> void newline

-- | Succeeds if the parser is looking at the end of a paragraph.
endOfPara :: JiraParser ()
endOfPara = eof
  <|> lookAhead blankline
  <|> lookAhead headerStart
  <|> lookAhead listItemStart
  <|> lookAhead tableStart
  <|> lookAhead panelStart
  where
    headerStart   = void $ char 'h' *> oneOf "123456" <* char '.'
    listItemStart = void $ many1 (oneOf "#*-") *> char ' '
    tableStart    = void $ skipSpaces *> many1 (char '|') *> char ' '
    panelStart    = void $ char '{' *> choice (map string blockNames)

blockNames :: [String]
blockNames = ["code", "noformat", "panel", "quote"]

-- | Variant of parsec's @notFollowedBy@ function which properly fails even if
-- the given parser does not consume any input (like @eof@ does).
notFollowedBy' :: Show a => JiraParser a -> JiraParser ()
notFollowedBy' p =
  let failIfSucceeds = unexpected . show <$> try p
      unitParser = return (return ())
  in try $ join (failIfSucceeds <|> unitParser)