packages feed

yamlet-1.0.0.0: src/Yamlet/Internal/Parser/Hints.hs

{-# OPTIONS_HADDOCK not-home #-}

-- | The messages of parse errors. The parser only knows the position at
-- which it failed, so these functions look at the input around it to name
-- the likely mistake, e.g. a missing colon after a key.
--
-- This module is intended for internal use only, and may change without warning
-- in subsequent releases.
module Yamlet.Internal.Parser.Hints
  ( unexpected
  , flowError
  , codePointName
  , firstTab
  , tabMessage
  , keyLengthMessage
  ) where

import Control.Monad
import Data.Char
import Data.List qualified as L
import Data.Maybe
import Data.Text qualified as T
import Data.Text.Array qualified as A
import Data.Word
import Numeric

import Yamlet.Internal.Chars
import Yamlet.Internal.Parser.Monad
import Yamlet.Internal.Parser.Scan
import Yamlet.Internal.Utils

-- | The location and the message of the error for the furthest position at
-- which the parser failed, and a tab before the position on its line that
-- can be the cause instead, with 'tabMessage'.
unexpected :: Env -> Int -> (Maybe Int, (Int, String))
unexpected input i = (tabCause, other)
  where
    tabCause :: Maybe Int
    tabCause = case indentationTab (i - 1) Nothing of
      Nothing
        | byteAt e i == COLON && firstColonFrom contentStart ->
            firstTab e lineStart contentStart
      t -> t

    other :: (Int, String)
    other
      | Just start <- propertiesLine =
          ( start
          , "an anchor or a tag cannot be on a line of its own here, write it after the key or the '-'"
          )
      | Just r <- blockMistake = r
      | afterComment =
          (i, "a comment ends a plain scalar, so this line cannot continue it")
      | Just colon <- aliasColon e i = (colon, aliasColonMessage)
      | otherwise = (i,) $ case byteAt e i of
          w
            | byteBefore e i == STAR && not (isAnchorChar w) ->
                "expected an alias name after '*'"
            | byteBefore e i == AMP && not (isAnchorChar w) ->
                "expected an anchor name after '&'"
            | w == 0 -> "unexpected end of input"
            | indented, Just msg <- indentationMistake -> msg
            | indented, Just msg <- mistakeIn e False i -> msg
            | indented, not alignedWithEntry -> "unexpected indentation"
            | isBreak w -> "unexpected end of line"
            | i > e.base && isBreak (byteBefore e i)
            , Just msg <- indentationMistake ->
                msg
            | w == COLON && firstColon && not (fitsKey e entryStart i) -> keyLengthMessage
            | w == COLON && multiLineKey ->
                "unexpected ':', a key must be on a single line"
            | w == COLON && firstColon && valueColon && onStartMarkerLine ->
                "unexpected ':', a mapping cannot start on the line of '---'"
            -- A colon on the first line of a key does not fail, so the scalar
            -- before this one started on a line above.
            | w == COLON
                && firstColon
                && valueColon
                && isJust (lineAbove (lineStartAt e i)) ->
                "unexpected ':', this line continues the scalar from the line above, check the indentation and the line above"
            | w == COLON && valueColon ->
                "unexpected ':', quote the value if it contains \": \""
            | itemAfterKey -> "unexpected '-', a list cannot start on the line of its key"
            | itemAfterProperty ->
                "unexpected '-', a list cannot start on the line of its anchor or tag"
            | itemAfterStartMarker ->
                "unexpected '-', a list cannot start on the line of '---'"
            | Just msg <- mistakeIn e False i -> msg
            | Just node <- endBefore -> unexpectedChar e i ++ " after the end of " ++ node
            | otherwise -> unexpectedChar e i

    e :: Env
    e = afterBoms input

    -- The node that ends before the index on its line, as in
    -- "key: "value" more".
    endBefore :: Maybe String
    endBefore
      | not (isNsChar (byteAt e i)) = Nothing
      | b == RBRACKET || b == RBRACE = Just "a flow collection"
      | (b == DQUOTE || b == SQUOTE) && j < i = Just "a quoted scalar"
      | otherwise = Nothing
      where
        j :: Int
        j = skipBackWhites e i

        b :: Word8
        b = byteBefore e j

    -- The index starts a line that looks like the continuation of a plain
    -- scalar, the closest line above that is not blank has a comment, and
    -- the content above ends with a plain scalar.
    afterComment :: Bool
    afterComment =
      i == skipSpaces e (lineStartAt e i)
        && isNsChar (byteAt e i)
        && not (isListItem i)
        && not (any isKeyColon [i .. lineEndAt e i - 1])
        && isNothing (mistakeIn e False i)
        && commentAbove (lineStartAt e i)
        && maybe False endsPlain (lineAbove (lineStartAt e i))
      where
        -- The line with the content at the index ends with a plain scalar,
        -- not with a quoted scalar, a flow collection, an alias, an anchor,
        -- a tag, an indicator or the header of a block scalar, and it is not
        -- a line of a block scalar.
        endsPlain :: Int -> Bool
        endsPlain k =
          let end = lineContentEnd e k
              start = wordStart e end
              b = byteBefore e end
              w = byteAt e start
          in end > k
               && b /= SQUOTE
               && b /= DQUOTE
               && b /= RBRACKET
               && b /= RBRACE
               && b /= COLON
               && w /= STAR
               && w /= AMP
               && w /= EXCL
               && not (end - start == 1 && (w == MINUS || w == QUESTION))
               && not (endsWithBlockHeader k)
               && not (inBlockScalar k)

        -- The closest line above that is indented less starts a block
        -- scalar.
        inBlockScalar :: Int -> Bool
        inBlockScalar k = go k
          where
            go :: Int -> Bool
            go j = case lineAbove (lineStartAt e j) of
              Nothing -> False
              Just above
                | columnOf above < columnOf k -> endsWithBlockHeader above
                | otherwise -> go above

        commentAbove :: Int -> Bool
        commentAbove start
          | start <= e.base = False
          | otherwise =
              let prev = lineStartAt e (start - 1)
                  k = skipWhites e (skipBoms e prev)
              in if isBreak (byteAt e k) then commentAbove prev else hasComment k

        -- A quoted scalar can contain " #", as in "\"x #y\"", so a quote
        -- that can close a scalar after the '#' shows that the '#' may not
        -- start a comment.
        hasComment :: Int -> Bool
        hasComment j = case L.find startsComment [j .. end - 1] of
          Just h -> not (any closingQuote [h + 1 .. end - 1])
          Nothing -> False
          where
            end :: Int
            end = lineEndAt e j

            startsComment :: Int -> Bool
            startsComment k = byteAt e k == HASH && (k == j || isWhite (byteBefore e k))

        closingQuote :: Int -> Bool
        closingQuote k =
          let b = byteAt e k in (b == SQUOTE || b == DQUOTE) && canEndFlowNode e (k + 1)

    -- The start of the line of the index if the line has only anchors and
    -- tags, as in "&anchor".
    propertiesLine :: Maybe Int
    propertiesLine =
      let start = skipSpaces e (lineStartAt e i)
      in if onlyProperties start then Just start else Nothing
      where
        onlyProperties :: Int -> Bool
        onlyProperties j =
          let b = byteAt e j
              next = skipWhites e (wordEnd j)
              b' = byteAt e next
          in (b == AMP || b == EXCL)
               && (b' == 0 || isBreak b' || b' == HASH || onlyProperties next)

        wordEnd :: Int -> Int
        wordEnd j = if isNsChar (byteAt e j) then wordEnd (j + 1) else j

    -- A key before the index that started on a line above. A key that ends
    -- here on one line does not fail.
    multiLineKey :: Bool
    multiLineKey =
      multiLineCollection
        || firstColon && (byteBefore e i == DQUOTE || byteBefore e i == SQUOTE)

    -- A flow collection ends before the index and starts on a line above.
    multiLineCollection :: Bool
    multiLineCollection =
      let b = byteBefore e i
      in (b == RBRACKET || b == RBRACE) && go (i - 2) 1 False
      where
        go :: Int -> Int -> Bool -> Bool
        go j depth crossed
          | j < e.base = False
          | c == RBRACKET || c == RBRACE = go (j - 1) (depth + 1) crossed
          | c == LBRACKET || c == LBRACE =
              if depth == 1 then crossed else go (j - 1) (depth - 1) crossed
          | otherwise = go (j - 1) depth (crossed || isBreak c)
          where
            c :: Word8
            c = byteAt e j

    onStartMarkerLine :: Bool
    onStartMarkerLine = isStartMarker e markerStart

    -- A byte order mark can come before a marker.
    markerStart :: Int
    markerStart = skipBoms e (lineStartAt e i)

    -- The start of the entry on the line, after any "- ".
    entryStart :: Int
    entryStart = skipListItems (skipSpaces e (lineStartAt e i))

    firstColon :: Bool
    firstColon = firstColonFrom entryStart

    -- No colon that ends a key is from the given index to the index of the
    -- error, other than in a flow collection.
    firstColonFrom :: Int -> Bool
    firstColonFrom start = go start 0
      where
        go :: Int -> Int -> Bool
        go j depth
          | j >= i = True
          | b == LBRACKET || b == LBRACE = go (j + 1) (depth + 1)
          | b == RBRACKET || b == RBRACE = go (j + 1) (max 0 (depth - 1))
          | depth == 0 && isKeyColon j = False
          | otherwise = go (j + 1) depth
          where
            b :: Word8
            b = byteAt e j

    -- A list item right after a key, as in "a: - b".
    itemAfterKey :: Bool
    itemAfterKey = isListItem i && byteBefore e (skipBackWhites e i) == COLON

    -- A list item right after an anchor or a tag, as in "&a - b".
    itemAfterProperty :: Bool
    itemAfterProperty =
      let j = skipBackWhites e i
          b = byteAt e (wordStart e j)
      in isListItem i && j < i && (b == AMP || b == EXCL)

    itemAfterStartMarker :: Bool
    itemAfterStartMarker =
      isListItem i
        && onStartMarkerLine
        && skipBackWhites e i == markerStart + markerLength

    -- A colon that ends a word and precedes white space, as in an unquoted
    -- value like "Error: file not found".
    valueColon :: Bool
    valueColon =
      isNsChar (byteBefore e i)
        && (let w = byteAt e (i + 1) in w == 0 || isWhite w || isBreak w)

    -- Only spaces precede the index on its line.
    indented :: Bool
    indented = i > e.base && byteBefore e i == SPACE && go (i - 1)
      where
        go :: Int -> Bool
        go j
          | j <= e.base = True
          | otherwise = case byteBefore e j of
              SPACE -> go (j - 1)
              w -> isBreak w

    -- The index is at the column of a list item or a key on a line above, so
    -- the content is the mistake, not the indentation. A line of a block
    -- scalar above is neither.
    alignedWithEntry :: Bool
    alignedWithEntry = case entryAbove (columnOf i) (lineStartAt e i) of
      Just k -> isListItem k || any isKeyColon [k .. lineContentEnd e k - 1]
      Nothing -> False

    lineStart :: Int
    lineStart = lineStartAt e i

    -- The content of the line of the index after the white space and the
    -- indicators of block entries, as in "- ? key". A plain scalar can follow
    -- a tab there, but a key cannot.
    contentStart :: Int
    contentStart = go lineStart
      where
        go :: Int -> Int
        go j =
          let k = skipWhites e j
          in if isBlockIndicator k then go (k + 1) else k

    -- The first tab in the indentation before the index, if only white space
    -- and the indicators of block entries precede the index on its line.
    indentationTab :: Int -> Maybe Int -> Maybe Int
    indentationTab j tab
      | j < e.base = tab'
      | isBlockIndicator j = indentationTab (j - 1) tab
      | otherwise = case A.unsafeIndex e.array j of
          SPACE -> indentationTab (j - 1) tab
          TAB -> indentationTab (j - 1) (Just j)
          w
            | isBreak w -> tab'
            | otherwise -> Nothing
      where
        tab' :: Maybe Int
        tab' = if byteAt e i == TAB then Just (fromMaybe i tab) else tab

    isBlockIndicator :: Int -> Bool
    isBlockIndicator j =
      let w = byteAt e j
      in (w == MINUS || w == QUESTION || w == COLON) && isWhite (byteAt e (j + 1))

    -- The error for a line of a block collection that lacks the space after
    -- "-" or the ":" after a key, if the entries above it at the same
    -- position are list items or mapping entries.
    blockMistake :: Maybe (Int, String)
    blockMistake = do
      guard $ start < stop
      k <- entryAbove column (lineStartAt e stop)
      if
        | isListItem k && byteAt e start == MINUS && stop == start + 1 ->
            Just (stop, "expected a space after '-'")
        | not (isListItem k)
            && (w == 0 || isBreak w || stop < i)
            && not (any isKeyColon [afterKey .. stop - 1]) ->
            Just $ case (keyEnd, filter tightColon [afterKey .. stop - 1]) of
              (Nothing, _) -> (start, "unterminated " ++ quotedName ++ " scalar")
              (Just end, _) | end > stop -> (stop, "a key must be on a single line")
              (_, colon : _) -> (colon + 1, "expected a space after ':'")
              (_, []) -> (stop, "expected ':' after the key")
        | otherwise -> Nothing
      where
        -- The parser fails at a comment after the content, as in "key # note".
        stop :: Int
        stop
          | byteAt e i == HASH && isWhite (byteBefore e i) = skipBackWhites e i
          | otherwise = i

        -- The index after the quoted scalar that starts the line, or the start
        -- of the line without a quote, or 'Nothing' if the scalar does not
        -- end. A colon inside the scalar does not end a key.
        keyEnd :: Maybe Int
        keyEnd
          | quote == DQUOTE || quote == SQUOTE = closing (start + 1)
          | otherwise = Just start
          where
            closing :: Int -> Maybe Int
            closing j
              | j >= e.end = Nothing
              | quote == SQUOTE && b == SQUOTE && byteAt e (j + 1) == SQUOTE =
                  closing (j + 2)
              | quote == DQUOTE && b == BACKSLASH = closing (j + 2)
              | b == quote = Just (j + 1)
              | otherwise = closing (j + 1)
              where
                b :: Word8
                b = byteAt e j

        -- The index after the key on the line.
        afterKey :: Int
        afterKey = fromMaybe stop keyEnd

        quote :: Word8
        quote = byteAt e start

        quotedName :: String
        quotedName = if quote == DQUOTE then "double-quoted" else "single-quoted"

        w :: Word8
        w = byteAt e stop

        start :: Int
        start = skipSpaces e (lineStartAt e stop)

        column :: Int
        column = start - lineStartAt e stop

        -- A colon before a word, as in "key:value", but not in "http://".
        tightColon :: Int -> Bool
        tightColon j = byteAt e j == COLON && startsWord (byteAt e (j + 1))

        startsWord :: Word8 -> Bool
        startsWord b =
          (isAsciiByte b && isAlphaNum (chr (fromIntegral b)))
            || b == SQUOTE
            || b == DQUOTE
            || b == LBRACKET
            || b == LBRACE

    -- The closest entry above the line that starts at the index, at the
    -- column. An entry can follow "- " on its line, as in "- key: value".
    entryAbove :: Int -> Int -> Maybe Int
    entryAbove column from = do
      k <- lineAbove from
      let indent = columnOf k
          entry = skipListItems k
      if
        | indent == column -> Just k
        | columnOf entry == column -> Just entry
        | indent < column -> Nothing
        | otherwise -> entryAbove column (lineStartAt e k)

    -- The error for content at the index that starts a line with a wrong
    -- indentation, if the lines above show the likely mistake: a list item
    -- among mapping entries or the other way round, or a line of a block
    -- scalar with too little indentation.
    indentationMistake :: Maybe String
    indentationMistake = go (lineStartAt e i)
      where
        column :: Int
        column = columnOf i

        -- Look at the lines above, up to the first line with less
        -- indentation.
        go :: Int -> Maybe String
        go start = do
          k <- lineAbove start
          let indent = columnOf k
          if
            | indent > column -> go (lineStartAt e k)
            | indent < column ->
                if endsWithBlockHeader k
                  then
                    Just
                      "unexpected indentation, the line has less indentation than the block scalar above it"
                  else Nothing
            | isListItem k && not (isListItem i) && not (isFlowIndicator (byteAt e i)) ->
                Just $
                  if hasKey i
                    then "unexpected key among list items"
                    else "unexpected value among list items"
            | not (isListItem k) && isListItem i ->
                Just "unexpected list item among mapping entries"
            | otherwise -> Nothing

    -- The line from the content at the index ends with the header of a block
    -- scalar, e.g. "key: |-".
    endsWithBlockHeader :: Int -> Bool
    endsWithBlockHeader k =
      let h = skipIndicators (lineContentEnd e k)
      in h > k
           && (let b = byteAt e (h - 1) in b == PIPE || b == GREATER)
           && (h - 1 == k || isWhite (byteAt e (h - 2)))
      where
        skipIndicators :: Int -> Int
        skipIndicators j =
          let b = byteBefore e j
          in if b == MINUS || b == PLUS || isDecDigit b then skipIndicators (j - 1) else j

    -- The first content of the closest line above the line that starts at the
    -- index. Blank lines and comment lines do not count.
    lineAbove :: Int -> Maybe Int
    lineAbove start = skipSpaces e . skipBoms e <$> contentLineAbove e start

    -- A byte order mark can start the first line of a document, before its
    -- indentation.
    columnOf :: Int -> Int
    columnOf j = j - skipBoms e (lineStartAt e j)

    -- The index after the "- " indicators at the index, as in "- - key: value".
    skipListItems :: Int -> Int
    skipListItems j = if isListItem j then skipListItems (skipWhites e (j + 1)) else j

    -- A colon that ends an implicit key is at the index.
    isKeyColon :: Int -> Bool
    isKeyColon j =
      byteAt e j == COLON
        && (let b = byteAt e (j + 1) in b == 0 || isWhite b || isBreak b)

    -- The line from the content at the index has a key, explicit or
    -- implicit.
    hasKey :: Int -> Bool
    hasKey j =
      (byteAt e j == QUESTION && (let b = byteAt e (j + 1) in b == 0 || isWhite b || isBreak b))
        || any isKeyColon [j .. lineContentEnd e j - 1]

    -- A block sequence entry starts at the index.
    isListItem :: Int -> Bool
    isListItem j =
      byteAt e j == MINUS
        && (let b = byteAt e (j + 1) in b == 0 || isWhite b || isBreak b)

-- | The colon that ends an alias name before the index, as in @*x: 1@. An
-- alias name can contain a colon.
aliasColon :: Env -> Int -> Maybe Int
aliasColon e i =
  let j = skipBackWhites e i
      start = wordStart e j
  in if j > start && byteBefore e j == COLON && byteAt e start == STAR
       then Just (j - 1)
       else Nothing

aliasColonMessage :: String
aliasColonMessage =
  "the name of the alias includes the ':', write a space before ':' if the alias is a key"

-- | The location and the message of the error at the index inside a flow
-- collection: a common mistake if the input there shows one, or else the
-- index and the given message.
flowError :: Env -> Int -> String -> (Int, String)
flowError e i msg = case aliasColon e i of
  Just colon -> (colon, aliasColonMessage)
  Nothing -> (i, fromMaybe msg (mistakeIn (afterBoms e) True i))

-- | The input without the byte order marks at its start. The hints look at
-- the content of the lines around an error, and the marks are not content
-- of the first line. The indices stay those of the input.
afterBoms :: Env -> Env
afterBoms e = e {base = skipBoms e e.base}

-- | The flag tells if the index is inside a flow collection.
mistakeIn :: Env -> Bool -> Int -> Maybe String
mistakeIn e flow i
  | isBom e i = Just "unexpected byte order mark"
  -- Inside a plain scalar, a '#' after other content does not stop the
  -- parser, so here it follows the end of another node, e.g. "x"#c.
  | w == HASH && isNsChar (byteBefore e i) =
      Just "unexpected '#', a comment needs a space before it"
  | w == COMMA
      && ( let b = byteBefore e (skipBack i)
           in b == COMMA || b == LBRACKET || b == LBRACE
         ) =
      Just "unexpected ',', a flow collection cannot have an empty entry"
  -- In the block style, these characters start a block scalar.
  | flow && (w == PIPE || w == GREATER) =
      Just $ unexpectedChar e i ++ ", a block scalar cannot be inside a flow collection"
  | w == STAR && not (isAnchorChar (byteAt e (i + 1))) =
      Just "expected an alias name after '*'"
  | w == STAR
      && (let b = byteAt e (wordStart e (skipBackWhites e i)) in b == AMP || b == EXCL) =
      Just "unexpected '*', an alias cannot have an anchor or a tag"
  | w == AMP && not (isAnchorChar (byteAt e (i + 1))) =
      Just "expected an anchor name after '&'"
  | afterQuote SQUOTE =
      Just $
        unexpectedChar e i
          ++ " after a single-quoted scalar, write '' for a quote inside it"
  | afterQuote DQUOTE =
      Just $
        unexpectedChar e i
          ++ " after a double-quoted scalar, write \\\" for a quote inside it"
  -- A '%' at the start of a line in the block style starts a directive.
  | not flow && w == PERCENT && isStartOfLine e i && isNsChar (byteAt e (i + 1)) =
      Just
        "unexpected '%', a directive needs '...' on a line above it to end the document"
  -- Other indicators start a node of another kind, e.g. '&' an anchor.
  | w == AT || w == GRAVE || w == PERCENT =
      Just $
        unexpectedChar e i ++ ", a plain scalar cannot start with it, quote the value"
  | otherwise = Nothing
  where
    w :: Word8
    w = byteAt e i

    -- The index after the last content before the white space and the line
    -- breaks that end at the index.
    skipBack :: Int -> Int
    skipBack j
      | isWhite (byteBefore e j) || isBreak (byteBefore e j) = skipBack (j - 1)
      | otherwise = j

    -- Content right after a quote, as in 'it's'. A plain scalar can hold a
    -- quote, so the quote closes a quoted scalar. A colon there ends a key.
    afterQuote :: Word8 -> Bool
    afterQuote q =
      byteBefore e i == q
        && isNsChar w
        && not (isFlowIndicator w)
        && w /= COLON
        && not quoteInTag

    -- A quote can be a character of a tag, as in "!'".
    quoteInTag :: Bool
    quoteInTag = byteBefore e (tagStart i) == EXCL
      where
        tagStart :: Int -> Int
        tagStart j = if isTagChar (byteBefore e j) then tagStart (j - 1) else j

-- | The index after the content of the line from the content at the index,
-- before its comment.
lineContentEnd :: Env -> Int -> Int
lineContentEnd e = skipBackWhites e . go
  where
    go :: Int -> Int
    go j
      | b == 0 || isBreak b = j
      | b == HASH && isWhite (byteBefore e j) = j
      | otherwise = go (j + 1)
      where
        b :: Word8
        b = byteAt e j

-- | The index after the last content before the white space that ends at the
-- index.
skipBackWhites :: Env -> Int -> Int
skipBackWhites e i = if isWhite (byteBefore e i) then skipBackWhites e (i - 1) else i

-- | The start of the word that ends at the index, e.g. of an anchor or an
-- alias with its indicator.
wordStart :: Env -> Int -> Int
wordStart e i = if isAnchorChar (byteBefore e i) then wordStart e (i - 1) else i

unexpectedChar :: Env -> Int -> String
unexpectedChar e i
  | isAsciiByte w = "unexpected " ++ show (chr (fromIntegral w))
  | isPrint c = "unexpected '" ++ [c] ++ "'"
  | otherwise = "unexpected " ++ codePointName c
  where
    w :: Word8
    w = byteAt e i

    c :: Char
    c = T.head (slice e i e.end)

-- | The first tab from the first index to before the second.
firstTab :: Env -> Int -> Int -> Maybe Int
firstTab e i j = L.find (\k -> byteAt e k == TAB) [i .. j - 1]

tabMessage :: String
tabMessage = "tabs cannot be used for indentation"

keyLengthMessage :: String
keyLengthMessage =
  "a key can be at most "
    ++ show maxImplicitKeyLength
    ++ " characters long, write a longer key after '? '"

-- | The code point of a character, e.g. U+0007, for a character that an error
-- cannot show.
codePointName :: Char -> String
codePointName c =
  let hex = map toUpper (showHex (ord c) "")
  in "U+" ++ replicate (4 - length hex) '0' ++ hex