nyan-interpolation-core-0.9: src/Text/Interpolation/Nyan/Core/Internal/Parser.hs
-- SPDX-FileCopyrightText: 2022 Serokell <https://serokell.io/>
--
-- SPDX-License-Identifier: MPL-2.0
module Text.Interpolation.Nyan.Core.Internal.Parser where
import Control.Applicative (many, optional)
import Control.Monad (guard, when, (<=<))
import Control.Monad.State (MonadState, execStateT, get, put)
import Data.Bifunctor (first)
import Data.Char (isAlphaNum, isSpace)
import Data.Foldable (asum)
import Data.Functor (($>))
import Data.Text (Text)
import qualified Data.Text as T
import Fmt (Builder, build, fmt)
import Text.Interpolation.Nyan.Core.Internal.Base
import Text.Megaparsec (Parsec, customFailure, eof, errorBundlePretty, label, lookAhead, parse,
single, takeWhile1P, takeWhileP)
import Text.Megaparsec.Error (ShowErrorComponent (..))
newtype OptionChanged = OptionChanged Bool
deriving stock (Show, Eq)
-- | An accumulator for switch options during parsing.
data SwitchesOptionsBuilder = SwitchesOptionsBuilder
{ spacesTrimmingB :: (OptionChanged, Maybe Bool)
, indentationStrippingB :: (OptionChanged, Maybe Bool)
, leadingNewlineStrippingB :: (OptionChanged, Maybe Bool)
, trailingSpacesStrippingB :: (OptionChanged, Maybe Bool)
, returnTypeB :: (OptionChanged, Maybe ReturnType)
, reducedNewlinesB :: (OptionChanged, Maybe Bool)
, monadicB :: (OptionChanged, Maybe Bool)
, previewLevelB :: PreviewLevel
}
toSwitchesOptionsBuilder :: DefaultSwitchesOptions -> SwitchesOptionsBuilder
toSwitchesOptionsBuilder DefaultSwitchesOptions{..} =
SwitchesOptionsBuilder
{ spacesTrimmingB = (OptionChanged False, defSpacesTrimming)
, indentationStrippingB = (OptionChanged False, defIndentationStripping)
, leadingNewlineStrippingB = (OptionChanged False, defLeadingNewlineStripping)
, trailingSpacesStrippingB = (OptionChanged False, defTrailingSpacesStripping)
, returnTypeB = (OptionChanged False, defReturnType)
, reducedNewlinesB = (OptionChanged False, defMonadic)
, monadicB = (OptionChanged False, defMonadic)
, previewLevelB = PreviewNone
}
finalizeSwitchesOptions :: MonadFail m => SwitchesOptionsBuilder -> m SwitchesOptions
finalizeSwitchesOptions SwitchesOptionsBuilder{..} = do
spacesTrimming <- fromOptional "spaces trimming" spacesTrimmingB
indentationStripping <- fromOptional "indentation stripping" indentationStrippingB
leadingNewlineStripping <- fromOptional "leading newline stripping" leadingNewlineStrippingB
trailingSpacesStripping <- fromOptional "trailing spaces stripping" trailingSpacesStrippingB
returnType <- fromOptional "return type" returnTypeB
reducedNewlines <- fromOptional "reduced newlines" reducedNewlinesB
monadic <- fromOptional "monadic" monadicB
let previewLevel = previewLevelB
return SwitchesOptions{..}
where
fromOptional desc (_, mval) = case mval of
Nothing -> fail $ "Switch for " <> desc <> " must be specified"
Just val -> pure val
type SwitchesOptionsSetter m = (MonadState SwitchesOptionsBuilder m, MonadFail m)
setIfNew
:: (MonadFail m, Eq a)
=> String -> a -> (OptionChanged, Maybe a) -> m (OptionChanged, Maybe a)
setIfNew desc new (OptionChanged ch, old)
| ch = fail $ "Modifying `" <> desc <> "` option for the second time"
| old == Just new = fail $ "Switch option `" <> desc <> "` is set redundantly"
| otherwise = return (OptionChanged True, Just new)
setSpacesTrimming :: SwitchesOptionsSetter m => Bool -> m ()
setSpacesTrimming enable = do
opts <- get
res <- setIfNew "spaces trimming" enable (spacesTrimmingB opts)
put opts{ spacesTrimmingB = res }
setIndentationStripping :: SwitchesOptionsSetter m => Bool -> m ()
setIndentationStripping enable = do
opts <- get
res <- setIfNew "indentation stripping" enable (indentationStrippingB opts)
put opts{ indentationStrippingB = res }
setLeadingNewlineStripping :: SwitchesOptionsSetter m => Bool -> m ()
setLeadingNewlineStripping enable = do
opts <- get
res <- setIfNew "leading newline stripping" enable (leadingNewlineStrippingB opts)
put opts{ leadingNewlineStrippingB = res }
setTrailingSpacesStripping :: SwitchesOptionsSetter m => Bool -> m ()
setTrailingSpacesStripping enable = do
opts <- get
res <- setIfNew "trailing spaces stripping" enable (trailingSpacesStrippingB opts)
put opts{ trailingSpacesStrippingB = res }
setReducedNewlines :: SwitchesOptionsSetter m => Bool -> m ()
setReducedNewlines enable = do
opts <- get
res <- setIfNew "reduced newlines" enable (reducedNewlinesB opts)
put opts{ reducedNewlinesB = res }
setMonadic :: SwitchesOptionsSetter m => Bool -> m ()
setMonadic enable = do
opts <- get
res <- setIfNew "monadic" enable (monadicB opts)
put opts{ monadicB = res }
setReturnType :: SwitchesOptionsSetter m => ReturnType -> m ()
setReturnType ty = do
opts <- get
res <- setIfNew "return type" ty (returnTypeB opts)
put opts{ returnTypeB = res }
accountPreview :: SwitchesOptionsSetter m => m ()
accountPreview = do
opts <- get
when (previewLevelB opts == maxBound) $
fail "Too high preview level"
put opts{ previewLevelB = toEnum $ fromEnum (previewLevelB opts) + 1 }
notAnyOf :: [Char -> Bool] -> Char -> Bool
notAnyOf ps c = not $ or (sequence ps c)
one :: a -> [a]
one = (: [])
data CustomParserFailure
= SwitchesHelpRequested DefaultSwitchesOptions
-- These instances are necessary for megaparsec
instance Eq CustomParserFailure where
a == b = compare a b == EQ
instance Ord CustomParserFailure where
SwitchesHelpRequested{} `compare` SwitchesHelpRequested{} = EQ
instance ShowErrorComponent CustomParserFailure where
showErrorComponent = \case
SwitchesHelpRequested defSOpts -> fmt $ switchesHelpMessage defSOpts
switchesSectionP :: DefaultSwitchesOptions -> Parsec CustomParserFailure Text SwitchesOptions
switchesSectionP defSOpts =
finalizeSwitchesOptions <=<
flip execStateT (toSwitchesOptionsBuilder defSOpts) $ many $ label switchLabel $ asum
[ asum
[ single 's' $> True
, single 'S' $> False
] >>= setSpacesTrimming
, asum
[ single 'd' $> True
, single 'D' $> False
] >>= setIndentationStripping
, asum
[ single 'a' $> True
, single 'A' $> False
] >>= setLeadingNewlineStripping
, asum
[ single 'z' $> True
, single 'Z' $> False
] >>= setTrailingSpacesStripping
, asum
[ single 'n' $> True
, single 'N' $> False
] >>= setReducedNewlines
, asum
[ single 'm' $> True
, single 'M' $> False
] >>= setMonadic
, asum
[ single 'B' $> AnyFromBuilder
, single 'b' $> ConcreteBuilder
, single 't' $> ConcreteText
, single 'T' $> ConcreteLText
] >>= setReturnType
, single '!' >> accountPreview
, single '?' >> customFailure (SwitchesHelpRequested defSOpts)
]
where
switchLabel = "switch option (type '?' here for help)"
switchesHelpMessage :: DefaultSwitchesOptions -> Builder
switchesHelpMessage sopts =
let _exhaustivnessCheck :: SwitchesOptions = SwitchesOptions
(error "")
(error "")
(error "")
(error "")
(error "")
(error "")
(error "")
(error "")
-- ↑ Note: If you edit this, you may also need to update
-- the help messages below.
in mconcat
[ "\nHelp on switches:\n"
, helpOnOptions (defSpacesTrimming sopts)
[ ("s", "enable spaces trimming", Just True)
, ("S", "disable spaces trimming", Just False)
]
, helpOnOptions (defIndentationStripping sopts)
[ ("d", "enable indentation stripping", Just True)
, ("D", "disable indentation stripping", Just False)
]
, helpOnOptions (defLeadingNewlineStripping sopts)
[ ("a", "enable leading newline stripping", Just True)
, ("A", "disable leading newline stripping", Just False)
]
, helpOnOptions (defTrailingSpacesStripping sopts)
[ ("z", "enable trailing spaces stripping", Just True)
, ("Z", "disable trailing spaces stripping", Just False)
]
, helpOnOptions (defReducedNewlines sopts)
[ ("n", "enable newlines reducing", Just True)
, ("N", "disable newlines reducing", Just False)
]
, helpOnOptions (defMonadic sopts)
[ ("m", "enable monadic interpolated values", Just True)
, ("M", "disable monadic interpolated values", Just False)
]
, helpOnOptions (defReturnType sopts)
[ ("t", "return `Text`", Just ConcreteText)
, ("T", "return lazy `Text`", Just ConcreteLText)
, ("b", "return `Builder`", Just ConcreteBuilder)
, ("B", "return any text-like type (`FromBuilder a => a`)", Just AnyFromBuilder)
]
, helpOnOptions PreviewNone
[ ("!", "show rendered text (without substitutions) as a warning", PreviewExact)
, ("!!", "like ! but also marks invisible characters like spaces", PreviewInvisible)
]
]
where
helpOnOptions defVal available = mconcat
[ "· " <> build @Text switch <> " - " <> help <> "\n"
| (switch, help, val) <- available
, val /= defVal
]
intPieceP :: Ord e => Parsec e Text [ParsedIntPiece]
intPieceP = asum
[
-- consume normal text
one . PipString <$> takeWhile1P Nothing (notAnyOf [(== '\\'), (== '#'), isSpace])
-- potentially interpolator case
, single '#' *> do
mode <- takeWhileP Nothing \c ->
isAlphaNum c || c == '_'
asum
[ do
-- interpolator
_ <- single '{'
intTxt <- many $ asum
[ takeWhile1P (Just "interpolated piece") $ notAnyOf [(== '\\'), (== '}')]
, single '\\' >> T.singleton <$> asum
[ single '\\'
, single '}'
]
]
_ <- single '}'
return . one $ PipInt IntData
{ idMode = mode
, idCode = mconcat intTxt
}
-- just plain text
, return $ one . PipString $ "#" <> mode
]
-- escaped text
, single '\\' *> asum
[ one . PipString . T.singleton <$> single '\\'
, one . PipString . T.singleton <$> single '#'
-- trailing '\' cancels newline feed
, newline *> lineStart
]
-- newline
, (:) <$> newline <*> lineStart
-- fast spacing
, one . PipString <$> takeWhile1P Nothing isNonNewlineSpace
, fail "Unexpected: failed to consume some input"
]
where
newline = PipNewline . mconcat <$> sequence
[ maybe "" T.singleton <$> optional (single '\r')
, T.singleton <$> single '\n'
]
isNonNewlineSpace c = isSpace c && c /= '\n' && c /= '\r'
-- Parse indentation
lineStart = asum
[ lookAhead newline $> [PipEmptyLine]
, do
wss <- fromIntegral @Int @Word . T.length <$>
takeWhileP Nothing isNonNewlineSpace
return $ guard (wss > 0) $> PipLeadingWs wss
]
-- | Since the parser may produce several 'PipString' with different kind of
-- content (e.g. spaces and words), we would like to glue those before passing
-- the interpolated string to the next stage.
glueParsedStrings :: ParsedInterpolatedString -> ParsedInterpolatedString
glueParsedStrings = \case
[] -> []
-- TODO: use Builder here
PipString s1 : PipString s2 : ps -> glueParsedStrings (PipString (s1 <> s2) : ps)
p : ps -> p : glueParsedStrings ps
intStringP
:: DefaultSwitchesOptions
-> Parsec CustomParserFailure Text (SwitchesOptions, ParsedInterpolatedString)
intStringP sopts = do
switches <- switchesSectionP sopts
_ <- single '|'
pieces <- glueParsedStrings . concat <$> many intPieceP
eof
return (switches, pieces)
parseIntString
:: DefaultSwitchesOptions
-> Text
-> Either String (SwitchesOptions, ParsedInterpolatedString)
parseIntString defSOpts txt =
first errorBundlePretty $
parse (intStringP defSOpts) "int QQ" txt