fluent-syntax-1.0.0: src/Language/Fluent/Parser.hs
{- HLINT ignore "Use <$>" -}
{-# OPTIONS_GHC -Wno-name-shadowing #-}
module Language.Fluent.Parser (module Language.Fluent.Parser, Parser) where
import Control.Applicative (Alternative (many, some), optional, (<|>))
import Control.Monad (replicateM, unless, when)
import Data.Attoparsec.Combinator (choice, eitherP, endOfInput, lookAhead, sepBy)
import Data.Attoparsec.Text
( Parser
, char
, endOfLine
, match
, parseOnly
, peekChar
, satisfy
, string
, takeWhile
, takeWhile1
)
import Data.Bifunctor (first)
import Data.Char (chr, isAsciiLower, isAsciiUpper, isDigit, isHexDigit, ord)
import Data.Either (isLeft, isRight, rights)
import Data.Functor (void, (<&>))
import Data.List qualified as List
import Data.List.NonEmpty (some1)
import Data.List.NonEmpty qualified as NonEmpty
import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing)
import Data.Text (Text)
import Data.Text qualified as Text
import GHC.Ix (inRange)
import Language.Fluent.AST
import Numeric (readHex)
import Prelude hiding (take, takeWhile)
parse :: Parser a -> Text -> Either String a
parse p = parseOnly $ p <* endOfInput
parseNamed :: String -> Parser a -> Text -> Either String a
parseNamed name p t = first (const $ "Invalid " <> name <> ": " <> show t) $ parse p t
parseIdentifier :: Text -> Either String Identifier
parseIdentifier = parseNamed "identifier" identifier
parseResource :: Text -> Either String Resource
parseResource = parseNamed "resource" resource
resource :: Parser Resource
resource = Resource . rights <$> many (eitherP blankBlock entryOrJunk)
entryOrJunk :: Parser Entry
entryOrJunk = entry <* lookAhead entryEnd <|> JunkEntry <$> junk
entryEnd :: Parser ()
entryEnd = void endOfLine <|> endOfInput
junk :: Parser Text
junk = do
first <- line
when (Text.null first) $ fail "nothing left to skip"
rest <- many $ beforeEntry *> line
pure . mconcat $ first : rest
where
beforeEntry :: Parser ()
beforeEntry = do
next <- peekChar
case next of
Just c | beginsEntry c -> fail "the next entry begins here"
Just _ -> pure ()
Nothing -> fail "end of input"
beginsEntry :: Char -> Bool
beginsEntry c = isAsciiLower c || isAsciiUpper c || c == '-' || c == '#'
line :: Parser Text
line = do
text <- takeWhile (/= '\n')
ending <- Text.singleton <$> char '\n' <|> pure mempty
pure $ text <> ending
entry :: Parser Entry
entry =
choice
[ commented
, MessageEntry <$> message Nothing
, TermEntry <$> term Nothing
]
commented :: Parser Entry
commented = do
(hashes, Comment -> comment) <- commentBlock
case hashes of
1 ->
optional
( (endOfLine *>)
. (<* lookAhead entryEnd)
. eitherP (message $ Just comment)
. term
$ Just comment
)
<&> \case
Nothing -> CommentEntry comment
Just (Left message) -> MessageEntry message
Just (Right term) -> TermEntry term
2 -> pure $ GroupCommentEntry comment
_ -> pure $ ResourceCommentEntry comment
commentBlock :: Parser (Int, Text)
commentBlock = do
(hashes, first) <- commentLine
rest <- many $ endOfLine *> ofSameKind hashes
pure (hashes, Text.intercalate "\n" (first : rest))
where
ofSameKind :: Int -> Parser Text
ofSameKind hashes = do
(found, content) <- commentLine
unless (found == hashes) . fail $
"expected a " <> replicate hashes '#' <> " comment, found a " <> replicate found '#' <> " one"
pure content
commentLine :: Parser (Int, Text)
commentLine = do
let hash = char '#'
hashes <- length . catMaybes <$> sequence [Just <$> hash, optional hash, optional hash]
content <-
choice
[ char ' ' *> lineText
, mempty <$ lookAhead (void (string "\r\n") <|> void (char '\n') <|> endOfInput)
]
pure (hashes, content)
lineText :: Parser Text
lineText = do
text <- takeWhile (/= '\n')
end <- peekChar
pure if end == Just '\n' then fromMaybe text $ Text.stripSuffix "\r" text else text
message :: Maybe Comment -> Parser Message
message comment = do
id <- identifier
optional blankInline
char '='
optional blankInline
value <- optional pattern
attributes <- many attribute
when (isNothing value && null attributes) $
fail "Message must have either a pattern or attributes"
pure Message{..}
term :: Maybe Comment -> Parser Term
term comment = do
char '-'
id <- identifier
optional blankInline
char '='
optional blankInline
value <- pattern
attributes <- many attribute
pure Term{..}
attribute :: Parser Attribute
attribute = do
endOfLine
optional blank
char '.'
i <- identifier
optional blankInline
char '='
optional blankInline
p <- pattern
pure $ Attribute i p
pattern :: Parser Pattern
pattern = Pattern . NonEmpty.fromList . dedent . NonEmpty.toList <$> some1 patternElement
-- | Apply <https://projectfluent.org/fluent/guide/multiline multi-line rules>
-- to the raw pattern elements.
dedent :: [PatternElement] -> [PatternElement]
dedent elements =
onLast (mapText $ Text.dropWhileEnd (`elem` blanks))
. onFirst (mapText $ Text.dropWhile (`elem` newlines))
$ strip <$> elements
where
newlines = "\r\n" :: String
blanks = " \r\n" :: String
commonIndent :: Int
commonIndent =
minimum $
maxBound
: [indentWidth t | BlockText t <- elements]
<> [indent | Placeable (BlockPlaceable indent _) <- elements]
indentWidth :: Text -> Int
indentWidth = Text.length . Text.takeWhile (== ' ') . Text.dropWhile (`elem` newlines)
strip :: PatternElement -> PatternElement
strip (BlockText (Text.span (`elem` newlines) -> (ns, rest))) =
BlockText (ns <> Text.drop commonIndent rest)
strip e = e
mapText :: (Text -> Text) -> PatternElement -> PatternElement
mapText f (InlineText t) = InlineText (f t)
mapText f (BlockText t) = BlockText (f t)
mapText _ e = e
onFirst, onLast :: (a -> a) -> [a] -> [a]
onFirst f (x : xs) = f x : xs
onFirst _ [] = []
onLast f = reverse . onFirst f . reverse
patternElement :: Parser PatternElement
patternElement =
choice
[ InlineText <$> inlineText
, BlockText <$> blockText
, Placeable <$> placeable
]
placeable :: Parser Placeable
placeable =
choice
[ InlinePlaceable <$> inlinePlaceable
, blockPlaceable
]
inlinePlaceable :: Parser Expression
inlinePlaceable = do
char '{'
optional blank
e <- expression
optional blank
char '}'
pure e
blockPlaceable :: Parser Placeable
blockPlaceable = do
blankBlock
indent <- maybe 0 Text.length <$> optional (takeWhile1 (== ' '))
BlockPlaceable indent <$> inlinePlaceable
expression :: Parser Expression
expression = do
i <- inlineExpression
choice
[ Select <$> do
optional blank
string "->"
optional blankInline
unless (choosesVariant i) $ fail "failed to choose a variant"
vs <- variantList
pure $ SelectExpression i vs
, do
unless (usableAsPlaceable i) $ fail "Attributes of terms cannot be used as placeables"
pure $ Inline i
]
where
choosesVariant :: InlineExpression -> Bool
choosesVariant (StringLiteralExpression _) = True
choosesVariant (NumberLiteralExpression _) = True
choosesVariant (VariableReference _) = True
choosesVariant (FunctionReference _ _) = True
choosesVariant (TermReference _ attribute _) = isJust attribute
choosesVariant _ = False
usableAsPlaceable :: InlineExpression -> Bool
usableAsPlaceable (TermReference _ (Just _) _) = False
usableAsPlaceable _ = True
inlineExpression :: Parser InlineExpression
inlineExpression =
choice
[ StringLiteralExpression <$> stringLiteral
, NumberLiteralExpression <$> numberLiteral
, FunctionReference <$> functionName <*> callArguments
, MessageReference <$> identifier <*> optional attributeAccessor
, TermReference
<$> (char '-' *> identifier)
<*> optional attributeAccessor
<*> optional callArguments
, VariableReference <$> (char '$' *> identifier)
, PlaceableExpression <$> inlinePlaceable
]
attributeAccessor :: Parser AttributeAccessor
attributeAccessor = AttributeAccessor <$> (char '.' *> identifier)
variant :: Parser Variant
variant = do
endOfLine
optional blank
isDefault <- isJust <$> optional (char '*')
key <- variantKey
optional blankInline
value <- pattern
pure Variant{..}
variantKey :: Parser VariantKey
variantKey = do
char '['
optional blank
vk <- eitherP numberLiteral identifier
optional blank
char ']'
pure $ VariantKey vk
variantList :: Parser VariantList
variantList = do
variants <- some1 variant
unless (any isDefault variants) $ fail "VariantList must have at least one default variant"
endOfLine
pure $ VariantList variants
callArguments :: Parser CallArguments
callArguments = do
optional blank
char '('
optional blank
args <- sepBy (eitherP namedArgument inlineExpression) comma
unless (null args) . void $ optional comma
optional blank
char ')'
unless (all isLeft $ dropWhile isRight args) $
fail "an argument standing on its place follows one that is named"
let names = [name | Left (NamedArgument name _) <- args]
unless (length (List.nub names) == length names) $ fail "a name is given twice"
pure $ CallArguments args
where
comma :: Parser ()
comma = void $ optional blank >> char ',' >> optional blank
namedArgument :: Parser NamedArgument
namedArgument = do
i <- identifier
optional blank
char ':'
optional blank
l <- eitherP stringLiteral numberLiteral
pure $ NamedArgument i l
identifier :: Parser Identifier
identifier = Identifier <$> (Text.cons <$> satisfy isAsciiLetter <*> tailLetters)
where
isAsciiLetter c = isAsciiLower c || isAsciiUpper c
tailLetters = takeWhile \c -> isAsciiLetter c || isDigit c || c == '_' || c == '-'
functionName :: Parser Identifier
functionName = do
name@(Identifier text) <- identifier
unless (Text.all capital text) $ fail "a function name should be written in all capitals"
pure name
where
capital :: Char -> Bool
capital c = isAsciiUpper c || isDigit c || c == '_' || c == '-'
numberLiteral :: Parser NumberLiteral
numberLiteral = do
sign <- optional $ string "-"
i <- digits
f <- optional $ Text.cons <$> char '.' <*> digits
pure . NumberLiteral . mconcat . catMaybes $ [sign, Just i, f]
where
digits = takeWhile1 (`elem` ['0' .. '9'])
stringLiteral :: Parser StringLiteral
stringLiteral = do
char '"'
(raw, value) <- match $ Text.pack <$> many quotedChar
char '"'
pure StringLiteral{..}
quotedChar :: Parser Char
quotedChar =
choice
[ satisfy \c -> isAnyChar c && notElem c ("\\\"\r\n" :: String)
, char '\\'
*> choice
[ satisfy (`elem` ("\\\"" :: String))
, char 'u' *> hexString 4
, char 'U' *> hexString 6
]
]
where
hexDigit :: Parser Char
hexDigit = satisfy isHexDigit
hexString :: Int -> Parser Char
hexString len = do
[(num, "")] <- readHex <$> replicateM len hexDigit
pure $ chr num
isAnyChar :: Char -> Bool
isAnyChar = inRange (0, 0x10FFFF) . ord
anyChar :: Parser Char
anyChar = satisfy isAnyChar
inlineText :: Parser Text
inlineText = takeWhile1 (`notElem` ("{}\r\n" :: String))
blockText :: Parser Text
blockText =
fmap mconcat . sequence $
[ Text.concat <$> some ("\n" <$ (optional blankInline *> endOfLine))
, takeWhile1 (== ' ')
, Text.cons <$> indentedChar <*> (inlineText <|> pure mempty)
]
indentedChar :: Parser Char
indentedChar = satisfy (`notElem` ("{}[*.\r\n" :: String))
blankInline :: Parser ()
blankInline = void . some . char $ ' '
blank :: Parser ()
blank = void . some $ blankInline <|> endOfLine
blankBlock :: Parser ()
blankBlock = void . some $ optional blankInline *> endOfLine