packages feed

fluent-syntax-1.1.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 (optional)
import Control.Exception (displayException)
import Control.Monad (replicateM, unless, void, when)
import Control.Monad.Extra (ifM, notM, unlessM, whenMaybeM, (&&^))
import Data.Attoparsec.Combinator (lookAhead)
import Data.Attoparsec.Extra
import Data.Attoparsec.Internal.Types (Pos (Pos))
import Data.Attoparsec.Text
    ( Parser
    , atEnd
    , endOfInput
    , match
    , parseOnly
    , peekChar
    , take
    , takeText
    , takeWhile
    )
import Data.Bifunctor (first)
import Data.Char (chr, isAsciiLower, isAsciiUpper, isDigit, isHexDigit)
import Data.Either (isLeft, isRight)
import Data.Foldable (toList)
import Data.List qualified as List
import Data.List.NonEmpty (NonEmpty, nonEmpty)
import Data.List.NonEmpty qualified as NonEmpty
import Data.Maybe (fromMaybe, isJust)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.Unsafe (takeWord8)
import Language.Fluent.AST
import Language.Fluent.Parser.Location qualified as Location
import Language.Fluent.Parser.Whitespace
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

-- | Parses an FTL file that must have no syntax errors. Fails with a line for each error.
parseResource :: Text -> Either String Resource
parseResource =
    first (List.intercalate "\n" . fmap displayException . toList)
        . rejectJunk
        . parseResourceLenient

-- | Parses an FTL file whatever is in it. An entry with a syntax error is a 'JunkEntry'.
parseResourceLenient :: Text -> Resource
parseResourceLenient text = case parse resource text of
    Right resource -> resource
    Left reason -> Resource [JunkEntry text ParseError{offset = 0, line = 1, column = 1, reason}]

-- | Fails with the syntax errors of a 'Resource'.
rejectJunk :: Resource -> Either (NonEmpty ParseError) Resource
rejectJunk resource@Resource{entries} =
    maybe (Right resource) Left $ nonEmpty [e | JunkEntry _ e <- entries]

resource :: Parser Resource
resource = do
    start <- position
    whole <- lookAhead takeText
    void skipBlankBlockToEnd
    entries <- whileJust . whenMaybeM (notM atEnd) $ (,) <$> entryOrJunk <*> skipBlankBlockToEnd
    pure . Resource . Location.locate start whole $ attachComments entries

-- | Attaches a comment to the message or term that follows it without a blank line.
attachComments :: [(Entry, Int)] -> [Entry]
attachComments [] = []
attachComments ((CommentEntry comment, 0) : (next, _) : rest) = attach comment next <> attachComments rest
  where
    attach :: Comment -> Entry -> [Entry]
    attach attached (MessageEntry Message{..}) =
        [MessageEntry Message{comment = Just attached, ..}]
    attach attached (TermEntry Term{..}) = [TermEntry Term{comment = Just attached, ..}]
    attach attached other = [CommentEntry attached, other]
attachComments ((entry, _) : rest) = entry : attachComments rest

-- | An entry, or the junk up to the next line that could begin one.
entryOrJunk :: Parser Entry
entryOrJunk = do
    start <- position
    observing (entry <* expectLineEnd) >>= \case
        Right entry -> pure entry
        Left (failedAt, reason) -> do
            rest <- lookAhead takeText
            let Pos failure = failedAt - start
            (junk, ()) <- match $ skipToNextEntryStart failure
            end <- position
            let reported
                    | "Expected" `List.isPrefixOf` reason = Location.settle rest failure
                    | otherwise = failure
                Pos offset = min (start + Pos reported) end
            pure $ JunkEntry junk ParseError{offset, line = 0, column = 0, reason}

-- | Skips to the next line that begins like an entry, from the line of the given byte offset.
skipToNextEntryStart :: Int -> Parser ()
skipToNextEntryStart failed = do
    rest <- lookAhead takeText
    let through = Text.take (Text.length (takeWord8 failed rest) + 1) rest
    void . take . max 0 $ Text.length (fst $ Text.breakOnEnd "\n" through) - 1
    skipLines
  where
    skipLines :: Parser ()
    skipLines = do
        void $ takeWhile (/= '\n')
        unlessM atEnd $ advance *> unlessM (all beginsEntry <$> peekChar) skipLines

    beginsEntry :: Char -> Bool
    beginsEntry c = isAsciiLetter c || c == '-' || c == '#'

entry :: Parser Entry
entry =
    peekChar >>= \case
        Just '#' -> commented
        Just '-' -> TermEntry <$> term
        Just c | isAsciiLetter c -> MessageEntry <$> message
        _ -> parseError "Expected an entry start"

isAsciiLetter :: Char -> Bool
isAsciiLetter c = isAsciiLower c || isAsciiUpper c

commented :: Parser Entry
commented = do
    (level, text) <- commentBlock
    let comment = Comment text
    pure case level of
        1 -> CommentEntry comment
        2 -> GroupCommentEntry comment
        _ -> ResourceCommentEntry comment

-- | A run of adjacent comment lines of the same level, joined by line breaks.
commentBlock :: Parser (Int, Text)
commentBlock = do
    (level, first) <- commentLine
    rest <-
        whileJust . whenMaybeM (isContinuation level) $ expectLineEnd *> (snd <$> commentLine)
    pure (level, Text.intercalate "\n" (first : rest))
  where
    isContinuation :: Int -> Parser Bool
    isContinuation level =
        lookAhead $
            atLineEnd
                &&^ (consumeNewline *> ((level ==) <$> hashes (level + 1)) &&^ endsMarker)

    endsMarker :: Parser Bool
    endsMarker =
        peekChar >>= \case
            Nothing -> pure True
            Just ' ' -> pure True
            Just '\n' -> pure True
            Just '\r' -> secondIs '\n'
            _ -> pure False

-- | Consumes up to @limit@ number signs, and counts them.
hashes :: Int -> Parser Int
hashes limit = go 0
  where
    go :: Int -> Parser Int
    go n
        | n >= limit = pure n
        | otherwise = ifM (nextIs '#') (advance *> go (n + 1)) $ pure n

commentLine :: Parser (Int, Text)
commentLine = do
    level <- hashes 3
    content <- ifM atLineEnd (pure Text.empty) $ expectChar ' ' *> lineText
    pure (level, 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 :: Parser Message
message = do
    id <- identifier
    skipBlankInline
    expectChar '='
    afterEquals <- position
    skipBlankInline
    value <- maybePattern
    attributes <- getAttributes
    unless (isJust value || not (null attributes)) $
        failAt afterEquals "Expected message to have a value or attributes"
    pure Message{id, value, attributes, comment = Nothing}

term :: Parser Term
term = do
    expectChar '-'
    id <- identifier
    skipBlankInline
    expectChar '='
    skipBlankInline
    value <- required "Expected term to have a value" maybePattern
    attributes <- getAttributes
    pure Term{id, value, attributes, comment = Nothing}

getAttributes :: Parser [Attribute]
getAttributes = whileJust $ optional attribute

-- | An attribute on a line of its own. A malformed one ends the attributes of its message.
attribute :: Parser Attribute
attribute = do
    unlessM atLineEnd $ parseError "Expected a line end before an attribute"
    expectLineEnd
    skipBlank
    expectChar '.'
    key <- identifier
    skipBlankInline
    expectChar '='
    skipBlankInline
    value <- required "Expected value" maybePattern
    pure $ Attribute key value

-- | The pattern of a value, which can start on the line of the value or on a line of its own.
maybePattern :: Parser (Maybe Pattern)
maybePattern =
    ifM (notM atLineEnd) (Just <$> pattern) $ whenMaybeM (lookAhead blockStart) pattern
  where
    blockStart :: Parser Bool
    blockStart =
        expectLineEnd *> skipBlankBlock *> (startsBlock <$> takeWhile (== ' ') <*> peekChar)

    startsBlock :: Text -> Maybe Char -> Bool
    startsBlock _ (Just '{') = True
    startsBlock indent (Just c) = isIndentedChar c && not (Text.null indent)
    startsBlock _ Nothing = False

pattern :: Parser Pattern
pattern =
    patternElements
        >>= maybe (parseError "Expected a value") (pure . Pattern) . NonEmpty.nonEmpty . dedent

patternElements :: Parser [PatternElement]
patternElements = whileJust patternElement

patternElement :: Parser (Maybe PatternElement)
patternElement =
    peekChar >>= \case
        Just '{' -> Just . Placeable . InlinePlaceable <$> inlinePlaceable
        Just '}' -> parseError "Unbalanced closing brace in a text element"
        Nothing -> pure Nothing
        Just _ -> ifM atLineEnd blockContinuation $ Just . InlineText <$> inlineText

-- | The block text or block placeable after a line break, if there is one.
blockContinuation :: Parser (Maybe PatternElement)
blockContinuation = attemptMaybe do
    blankLines <- skipBlankBlock
    indent <- takeWhile (== ' ')
    next <- peekChar
    block blankLines indent next
  where
    block :: Int -> Text -> Maybe Char -> Parser (Maybe PatternElement)
    block 0 _ _ = pure Nothing
    block _ indent (Just '{') =
        Just . Placeable . BlockPlaceable (Text.length indent) <$> inlinePlaceable
    block blankLines indent (Just c)
        | isIndentedChar c
        , not $ Text.null indent =
            Just . BlockText . ((Text.replicate blankLines "\n" <> indent) <>) <$> textRun
    block _ _ _ = pure Nothing

-- | Whether a line of a pattern can begin with the character.
isIndentedChar :: Char -> Bool
isIndentedChar c = c `notElem` ("{}[*.\n" :: String)

-- | Text up to a brace or a line break. A carriage return without a line feed is text.
textRun :: Parser Text
textRun = do
    text <- takeWhile (`notElem` ("{}\r\n" :: String))
    ifM
        (nextIs '\r' &&^ notM (secondIs '\n'))
        (advance *> (((text <> "\r") <>) <$> textRun))
        $ pure text

inlineText :: Parser Text
inlineText = do
    text <- textRun
    if Text.null text
        then parseError "Expected a character from range: anything but { } or a line end"
        else pure text

inlinePlaceable :: Parser Expression
inlinePlaceable = do
    expectChar '{'
    skipBlank
    e <- expression
    skipBlank
    expectChar '}'
    pure e

expression :: Parser Expression
expression = do
    i <- inlineExpression
    skipBlank
    ifM (nextIs '-' &&^ secondIs '>') (selected i) $ placed i
  where
    selected :: InlineExpression -> Parser Expression
    selected i = do
        unless (choosesVariant i) $ parseError "This expression cannot be used as a selector"
        advance *> advance
        skipBlankInline
        Select . SelectExpression i <$> variantList

    placed :: InlineExpression -> Parser Expression
    placed i = do
        unless (usableAsPlaceable i) $
            parseError "Attributes of terms cannot be used as placeables"
        pure $ Inline i

    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 =
    peekChar >>= \case
        Just '"' -> StringLiteralExpression <$> stringLiteral
        Just c | isDigit c || c == '-' -> numberOrTermReference
        Just '$' -> advance *> (VariableReference <$> identifier)
        Just c | isAsciiLetter c -> identifierStartingExpression
        Just '{' -> PlaceableExpression <$> inlinePlaceable
        _ -> parseError "Expected an inline expression"
  where
    numberOrTermReference :: Parser InlineExpression
    numberOrTermReference =
        peekChar >>= \case
            Just '-' ->
                peekSecond >>= \case
                    Just c | isDigit c -> NumberLiteralExpression <$> numberLiteral
                    _ -> termReference
            _ -> NumberLiteralExpression <$> numberLiteral

    termReference :: Parser InlineExpression
    termReference = do
        advance
        id <- identifier
        attr <- optionalAttributeAccessor
        args <- optionalCallArguments
        pure $ TermReference id attr args

    identifierStartingExpression :: Parser InlineExpression
    identifierStartingExpression = do
        id <- identifier
        called <- callFollows
        if called && isFunctionName id
            then FunctionReference id <$> callArguments
            else MessageReference id <$> optionalAttributeAccessor

    isFunctionName :: Identifier -> Bool
    isFunctionName (Identifier text) =
        Text.all (\c -> isAsciiUpper c || isDigit c || c == '_' || c == '-') text

    callFollows :: Parser Bool
    callFollows = lookAhead $ skipBlank *> nextIs '('

    optionalAttributeAccessor :: Parser (Maybe AttributeAccessor)
    optionalAttributeAccessor = whenMaybeM (nextIs '.') attributeAccessor

    optionalCallArguments :: Parser (Maybe CallArguments)
    optionalCallArguments = whenMaybeM callFollows callArguments

attributeAccessor :: Parser AttributeAccessor
attributeAccessor = AttributeAccessor <$> (expectChar '.' *> identifier)

callArguments :: Parser CallArguments
callArguments = do
    skipBlank
    expectChar '('
    skipBlank
    args <- arguments
    skipBlank
    expectChar ')'
    unless (all isLeft $ dropWhile isRight args) $
        parseError "A positional argument cannot follow a named one"
    let names = [name | Left (NamedArgument name _) <- args]
    unless (length (List.nub names) == length names) $ parseError "A named argument is given twice"
    pure $ CallArguments args
  where
    arguments :: Parser [Either NamedArgument InlineExpression]
    arguments = ifM (nextIs ')') (pure []) do
        arg <- argument
        skipBlank
        ifM (nextIs ',') (advance *> skipBlank *> ((arg :) <$> arguments)) $ pure [arg]

    argument :: Parser (Either NamedArgument InlineExpression)
    argument = do
        e <- inlineExpression
        skipBlank
        ifM (nextIs ':') (named e) (pure $ Right e)

    named :: InlineExpression -> Parser (Either NamedArgument InlineExpression)
    named (MessageReference name Nothing) = do
        advance
        skipBlank
        Left . NamedArgument name <$> literal
    named _ = parseError "An argument's name must be a plain identifier"

literal :: Parser (Either StringLiteral NumberLiteral)
literal =
    peekChar >>= \case
        Just c | isDigit c || c == '-' -> Right <$> numberLiteral
        Just '"' -> Left <$> stringLiteral
        _ -> parseError "Expected a literal"

identifier :: Parser Identifier
identifier = do
    first <-
        peekChar >>= \case
            Just c | isAsciiLetter c -> c <$ advance
            _ -> parseError "Expected a character from range: a-zA-Z"
    rest <- takeWhile \c -> isAsciiLetter c || isDigit c || c == '_' || c == '-'
    pure . Identifier $ Text.cons first rest

numberLiteral :: Parser NumberLiteral
numberLiteral = do
    sign <- ifM (nextIs '-') ("-" <$ advance) $ pure Text.empty
    i <- digits
    f <- ifM (nextIs '.') (advance *> (Text.cons '.' <$> digits)) $ pure Text.empty
    pure . NumberLiteral $ sign <> i <> f
  where
    digits :: Parser Text
    digits = takeWhile1With "0-9" isDigit

stringLiteral :: Parser StringLiteral
stringLiteral = do
    expectChar '"'
    (raw, chars) <- match quotedChars
    expectChar '"'
    pure StringLiteral{raw, value = Text.pack chars}
  where
    quotedChars :: Parser [Char]
    quotedChars = do
        lineEnd <- atLineEnd
        peekChar >>= \case
            Just c | not lineEnd && c /= '"' && c /= '\\' -> do
                advance
                (c :) <$> quotedChars
            Just '\\' -> do
                advance
                c <- escapeSequence
                (c :) <$> quotedChars
            _ -> pure []

    escapeSequence :: Parser Char
    escapeSequence =
        peekChar >>= \case
            Just c | c == '\\' || c == '"' -> c <$ advance
            Just 'u' -> advance *> hexEscape 4
            Just 'U' -> advance *> hexEscape 6
            _ -> parseError "Unknown escape sequence"

    hexEscape :: Int -> Parser Char
    hexEscape n = do
        digits <- replicateM n hexDigit
        case readHex digits of
            [(code, "")] -> pure $ chr code
            _ -> parseError "Invalid Unicode escape sequence"

    hexDigit :: Parser Char
    hexDigit =
        peekChar >>= \case
            Just c | isHexDigit c -> c <$ advance
            _ -> parseError "Expected a character from range: 0-9a-fA-F"

variantList :: Parser VariantList
variantList = do
    vs <- variants False
    case NonEmpty.nonEmpty vs of
        Nothing -> skipBlank *> parseError "Expected at least one variant"
        Just ne
            | not $ any isDefault ne ->
                skipBlank *> parseError "Expected one of the variants to be the default"
            | otherwise -> VariantList ne <$ expectLineEnd
  where
    variants :: Bool -> Parser [Variant]
    variants hasDefault =
        attemptMaybe variantStart >>= \case
            Nothing -> pure []
            Just isDefault -> do
                when (isDefault && hasDefault) do
                    Pos afterStar <- position
                    failAt (Pos $ afterStar - 1) "Only one variant can be the default"
                key <- variantKey
                skipBlankInline
                value <- required "Expected value" maybePattern
                (Variant{key, value, isDefault} :) <$> variants (hasDefault || isDefault)

    variantStart :: Parser (Maybe Bool)
    variantStart = ifM atLineEnd (expectLineEnd *> skipBlank *> marker) $ pure Nothing

    marker :: Parser (Maybe Bool)
    marker = do
        isDefault <- nextIs '*'
        when isDefault advance
        whenMaybeM (nextIs '[') $ pure isDefault

variantKey :: Parser VariantKey
variantKey = do
    expectChar '['
    skipBlank
    key <-
        peekChar >>= \case
            Just c | isDigit c || c == '-' -> Left <$> numberLiteral
            _ -> Right <$> identifier
    skipBlank
    expectChar ']'
    pure $ VariantKey key