packages feed

yamlet-1.0.0.0: src/Yamlet/Internal/Emit.hs

{-# LANGUAGE LinearTypes #-}
{-# OPTIONS_HADDOCK not-home #-}

-- | Building blocks of the YAML output.
--
-- This module is intended for internal use only, and may change without warning
-- in subsequent releases.
module Yamlet.Internal.Emit
  ( -- * Scalars
    plainSyntax
  , plainLines
  , singleQuoted
  , singleQuotedLines
  , quotedPlain
  , quotedPlainLines
  , doubleQuoted
  , doubleQuotedLines
  , literalBlock
  , foldedBlock
  , hasKeepIndicator
  , needsIndentIndicator

    -- * Other
  , indentStep
  , tagText
  , tagHandles
  , tagDirective
  , isPrintable
  , isScalarChar
  , spaces
  ) where

import Data.ByteString qualified as BS
import Data.Char
import Data.Containers.ListUtils
import Data.Maybe
import Data.Text qualified as T
import Data.Text.Builder.Linear qualified as B
import Data.Text.Builder.Linear.Buffer qualified as B
import Data.Text.Encoding qualified as T
import Data.Text.Internal qualified as T
import Numeric

import Yamlet.Internal.Chars
import Yamlet.Internal.Syntax
import Yamlet.Internal.Utils

-- | The number of spaces that the content of a block collection or a block
-- scalar is indented by, relative to its parent. The output style is fixed.
indentStep :: Int
indentStep = 2

-- | The text reads back as the same text if it is a plain scalar on one line,
-- in a flow collection if the flag is set. The check ignores the schema, so
-- e.g. @12@ passes.
plainSyntax :: Bool -> T.Text -> Bool
plainSyntax inFlow t = case T.uncons t of
  Nothing -> False
  Just (c, rest) ->
    firstOk c rest
      && isPlainChar c
      && valid c rest
      && not (textIsPrefixOf "---" t)
      && not (textIsPrefixOf "..." t)
  where
    -- The characters after the given one are valid in a plain scalar, and
    -- the text has no ": " or " #" and does not end with white space or a
    -- colon.
    valid :: Char -> T.Text -> Bool
    valid prev s = case T.uncons s of
      Nothing -> not (asciiChar isWhite prev) && prev /= ':'
      Just (c, s')
        | not (isPlainChar c) -> False
        | prev == ':' && c == ' ' -> False
        | prev == ' ' && c == '#' -> False
        | otherwise -> valid c s'

    -- YAML 1.1 parsers end a plain scalar at a question mark in a flow
    -- collection, or reject it. As one top-level function with the flag for
    -- both callers, it made the encode benchmark of the text input allocate
    -- several times more.
    isPlainChar :: Char -> Bool
    isPlainChar c =
      (c == ' ' || (isScalarChar c && c /= '\t'))
        && not (inFlow && (asciiChar isFlowIndicator c || c == '?'))

    -- YAML 1.1 parsers reject a plain scalar that starts with a colon in a
    -- flow collection.
    firstOk :: Char -> T.Text -> Bool
    firstOk c rest
      | inFlow && c == ':' = False
      | elem @[] c "-?:" = case T.uncons rest of
          Just (c', _) -> not (asciiChar isWhite c')
          Nothing -> False
      | otherwise = not (asciiChar isWhite c) && not (asciiChar isIndicator c)

-- | A plain scalar on the lines that start at the positions, with the lines
-- after the first one at the given indentation, if the text can be plain. It
-- is in a flow collection if the flag is set.
plainLines :: Bool -> Int -> [Int] -> T.Text -> Maybe B.Builder
plainLines inFlow indent starts t
  | plainSyntax inFlow first && all (plainNextLine . snd) rest =
      Just (onLines indent B.fromText ls)
  | otherwise = Nothing
  where
    ls@(first, rest) = flowLines False False (asciiChar isWhite) starts t

    -- The text reads back as the same text on a line of a plain scalar after
    -- the first line. Such a line can start with an indicator, but not with a
    -- comment.
    plainNextLine :: T.Text -> Bool
    plainNextLine l = case (T.uncons l, T.unsnoc l) of
      (Just (c, _), Just (_, lastChar)) ->
        c /= '#'
          && not (asciiChar isWhite c)
          && not (asciiChar isWhite lastChar)
          && lastChar /= ':'
          && T.all isPlainChar l
          && not (T.isInfixOf ": " l)
          && not (T.isInfixOf " #" l)
      _ -> False

    -- As in 'plainSyntax'.
    isPlainChar :: Char -> Bool
    isPlainChar c =
      (c == ' ' || (isScalarChar c && c /= '\t'))
        && not (inFlow && (asciiChar isFlowIndicator c || c == '?'))

-- | A single-quoted scalar on one line, if the text has no line breaks.
singleQuoted :: T.Text -> Maybe B.Builder
singleQuoted t
  | T.all (\c -> c == '\t' || isScalarChar c) t =
      Just $ "'" <> B.fromText (T.replace "'" "''" t) <> "'"
  | otherwise = Nothing

-- | A single-quoted scalar on the lines that start at the positions, as in
-- 'plainLines', if single quotes can hold the text.
singleQuotedLines :: Int -> [Int] -> T.Text -> Maybe B.Builder
singleQuotedLines indent starts t
  | null starts = singleQuoted t
  | all (T.all (\c -> c == '\t' || isScalarChar c)) (first : map snd rest) =
      Just $ "'" <> onLines indent (B.fromText . T.replace "'" "''") ls <> "'"
  | otherwise = Nothing
  where
    ls@(first, rest) = flowLines True False (asciiChar isWhite) starts t

-- | The quoted form of a plain scalar whose text cannot be plain: in single
-- quotes, or in double quotes if the text has a tab or a character that
-- single quotes cannot hold. A tab in single quotes is not visible.
quotedPlain :: T.Text -> B.Builder
quotedPlain = quotedPlainLines 0 []

-- | 'quotedPlain' on the lines that start at the positions, as in
-- 'plainLines'.
quotedPlainLines :: Int -> [Int] -> T.Text -> B.Builder
quotedPlainLines indent starts t
  | T.any (== '\t') t = doubleQuotedLines indent starts t
  | otherwise =
      fromMaybe (doubleQuotedLines indent starts t) (singleQuotedLines indent starts t)

-- | A double-quoted scalar with escapes for the characters that need them.
doubleQuoted :: T.Text -> B.Builder
doubleQuoted t = "\"" <> doubleQuotedText t <> "\""

-- | A double-quoted scalar on the lines that start at the positions, as in
-- 'plainLines'. The escapes of the tabs keep them at the ends of the lines.
doubleQuotedLines :: Int -> [Int] -> T.Text -> B.Builder
doubleQuotedLines indent starts t
  | null starts = doubleQuoted t
  | otherwise =
      "\""
        <> onLines indent doubleQuotedText (flowLines True True (== ' ') starts t)
        <> "\""

-- | The text of a double-quoted scalar, with escapes for the characters that
-- need them.
doubleQuotedText :: T.Text -> B.Builder
-- The loop writes to the buffer, and copies each run of characters without
-- escapes at once. A fold of builders over the characters allocates a
-- closure for each character since text 2.1.4, whose 'T.foldr' no longer
-- fuses, and the encode benchmark of the config input allocated more. A
-- fold of builders over the runs allocated more in the render benchmark of
-- the JSON input.
doubleQuotedText = B.Builder . go
  where
    go :: T.Text -> B.Buffer %1 -> B.Buffer
    go t b = case T.break needsEscape t of
      (run, rest) -> case T.uncons rest of
        Just (c, rest') -> go rest' (escape (b B.|> run) c)
        Nothing -> b B.|> run

    needsEscape :: Char -> Bool
    needsEscape c = c == '"' || c == '\\' || not (isScalarChar c)

    escape :: B.Buffer %1 -> Char -> B.Buffer
    escape b = \case
      '"' -> b B.|> "\\\""
      '\\' -> b B.|> "\\\\"
      '\n' -> b B.|> "\\n"
      '\t' -> b B.|> "\\t"
      '\r' -> b B.|> "\\r"
      '\0' -> b B.|> "\\0"
      c
        | ord c < 16 ^ xEscapeDigits -> b B.|> "\\x" B.|> hex xEscapeDigits (ord c)
        | ord c < 16 ^ uEscapeDigits -> b B.|> "\\u" B.|> hex uEscapeDigits (ord c)
        | otherwise -> b B.|> "\\U" B.|> hex bigUEscapeDigits (ord c)

    hex :: Int -> Int -> T.Text
    hex k i = T.pack (upperHex k i)

-- | The lines of a flow scalar that start at the positions: the first line,
-- and each next line with the number of empty lines above it, or 'Nothing'
-- after an escaped line break. A line break replaces a space of the text,
-- and an empty line replaces a line break of the text. A position where the
-- style cannot start a line and keep the text joins its two lines.
--
-- The first flag allows an empty first and last line, e.g. for a quoted
-- scalar. The second flag allows escaped line breaks. The parser drops a
-- white character at the start or the end of a line.
flowLines
  :: Bool -> Bool -> (Char -> Bool) -> [Int] -> T.Text -> (T.Text, [(Maybe Int, T.Text)])
flowLines quoted escapes white starts t = case splitLines starts t of
  first : rest -> go True first rest
  [] -> (t, [])
  where
    go :: Bool -> T.Text -> [T.Text] -> (T.Text, [(Maybe Int, T.Text)])
    go isFirst a = \case
      [] -> (a, [])
      b : rest -> case lineEnd isFirst (null rest) a b of
        Just (a', end) -> let (l, ls) = go False b rest in (a', (end, l) : ls)
        Nothing -> go isFirst (join a b) rest

    -- The pieces of 'splitLines' follow each other in the array of the text,
    -- so two of them join without a copy. A copy at each join would make the
    -- time quadratic in the number of lines.
    join :: T.Text -> T.Text -> T.Text
    join (T.Text arr off len) b@(T.Text _ _ len')
      | len == 0 = b
      | otherwise = T.Text arr off (len + len')

    -- The first line without the text that the line break replaces, and the
    -- number of empty lines.
    lineEnd :: Bool -> Bool -> T.Text -> T.Text -> Maybe (T.Text, Maybe Int)
    lineEnd isFirst isLast a b
      | not startOk = Nothing
      | Just (a', ' ') <- T.unsnoc a, endOk a' = Just (a', Just 0)
      | k > 0, endOk a'' = Just (a'', Just k)
      | escapes = Just (a, Nothing)
      | otherwise = Nothing
      where
        startOk :: Bool
        startOk = case T.uncons b of
          Just (c, _) -> not (white c)
          Nothing -> quoted && isLast

        endOk :: T.Text -> Bool
        endOk x = case T.unsnoc x of
          Just (_, c) -> not (white c)
          Nothing -> quoted && isFirst

        k :: Int
        k = T.length (T.takeWhileEnd (== '\n') a)

        a'' :: T.Text
        a'' = T.dropEnd k a

-- | The flow scalar on its lines, with each line after the first one at the
-- given indentation.
onLines :: Int -> (T.Text -> B.Builder) -> (T.Text, [(Maybe Int, T.Text)]) -> B.Builder
onLines indent text (first, rest) =
  text first <> mconcat [lineBreak end <> spaces indent <> text l | (end, l) <- rest]
  where
    lineBreak :: Maybe Int -> B.Builder
    lineBreak = \case
      Just k -> B.fromText (T.replicate (k + 1) "\n")
      Nothing -> "\\\n"

-- | The text split at the positions. A position that does not come after the
-- one before it is ignored, and a position after the end of the text ends
-- the split.
splitLines :: [Int] -> T.Text -> [T.Text]
splitLines = go 0
  where
    go :: Int -> [Int] -> T.Text -> [T.Text]
    go at starts s = case starts of
      p : rest
        | p <= at -> go at rest s
        | T.compareLength s (p - at) == LT -> [s]
        | otherwise -> let (a, b) = T.splitAt (p - at) s in a : go p rest b
      [] -> [s]

-- | The header and the content lines of a literal block scalar, with the
-- content at the given indentation.
literalBlock :: Int -> T.Text -> Maybe (B.Builder, B.Builder)
literalBlock indent t = do
  (header, body, trailing) <- blockParts True t
  let content
        -- The line break of the header comes first, and each empty line
        -- below it is one line break of the text.
        | T.null body = B.fromText (T.replicate trailing "\n")
        | otherwise =
            mconcat (map (line indent) (T.splitOn "\n" body))
              <> B.fromText (T.replicate (trailing - 1) "\n")
  Just ("|" <> header, content)

-- | The header and the content lines of a folded block scalar, with the
-- content at the given indentation. It has no keep indicator. Each line of
-- the text becomes one line of the output, or several lines if the lines
-- start at the positions.
foldedBlock :: Int -> [Int] -> T.Text -> Maybe (B.Builder, B.Builder)
foldedBlock indent starts t = do
  (header, body, _) <- blockParts False t
  let (leading, rest) = span T.null (if T.null body then [] else T.splitOn "\n" body)
      content =
        mconcat (replicate (length leading) "\n")
          <> go Nothing (length leading) starts (groups rest)
  Just (">" <> header, content)
  where
    -- The lines with content, each with the number of empty lines before it.
    groups :: [T.Text] -> [(Int, T.Text)]
    groups ls = case span T.null ls of
      (_, []) -> []
      (empties, l : ls') -> (length empties, l) : groups ls'

    -- A line break between two lines that start with content folds into a
    -- space, so the output needs one empty line more there. The group starts
    -- at the offset.
    go :: Maybe T.Text -> Int -> [Int] -> [(Int, T.Text)] -> B.Builder
    go prev offset ss = \case
      [] -> mempty
      (empties, l) : ls ->
        let extra = case prev of
              Just p | not (isSpaced p) && not (isSpaced l) -> 1
              _ -> 0
            separator = case prev of
              Just _ -> mconcat (replicate (empties + extra) "\n")
              Nothing -> mempty
            lineStart = offset + empties
            lineEnd = lineStart + T.length l
            (inLine, ss') = span (< lineEnd) (dropWhile (<= lineStart) ss)
        in separator
             <> mconcat
               (map (line indent) (lineParts (map (subtract lineStart) inLine) l))
             <> go (Just l) (lineEnd + 1) ss' ls

    -- The parts of a line of the text that start at the positions. A line
    -- break replaces a space between two parts that start with content.
    lineParts :: [Int] -> T.Text -> [T.Text]
    lineParts ps l
      | null ps || isSpaced l = [l]
      | otherwise = join (splitLines ps l)
      where
        join :: [T.Text] -> [T.Text]
        join = \case
          a : b : rest
            | Just (a', ' ') <- T.unsnoc a
            , Just (c, _) <- T.uncons b
            , c /= ' ' && c /= '\t' ->
                a' : join (b : rest)
            | otherwise -> join (a <> b : rest)
          parts -> parts

    isSpaced :: T.Text -> Bool
    isSpaced l = case T.uncons l of
      Just (c, _) -> c == ' ' || c == '\t'
      Nothing -> False

-- | A block scalar with the text has the keep indicator, so the empty lines at
-- its end are its content. Without content, the clip indicator drops the
-- line breaks too.
hasKeepIndicator :: T.Text -> Bool
hasKeepIndicator t = trailing > 1 || T.null body && trailing > 0
  where
    body :: T.Text
    body = T.dropWhileEnd (== '\n') t

    trailing :: Int
    trailing = T.length t - T.length body

-- | The header of a block scalar, its content without the trailing line breaks
-- and the number of these line breaks. The flag allows the keep indicator for
-- trailing empty lines.
blockParts :: Bool -> T.Text -> Maybe (B.Builder, T.Text, Int)
blockParts allowKeep t
  | not (T.all (\c -> c == '\n' || c == '\t' || isScalarChar c) t) = Nothing
  | keep && not allowKeep = Nothing
  | otherwise = Just (indicator <> chomping, body, trailing)
  where
    keep :: Bool
    keep = hasKeepIndicator t

    body :: T.Text
    body = T.dropWhileEnd (== '\n') t

    trailing :: Int
    trailing = T.length t - T.length body

    indicator :: B.Builder
    indicator = if needsIndentIndicator t then B.fromDec indentStep else mempty

    chomping :: B.Builder
    chomping
      | trailing == 0 = "-"
      | keep = "+"
      | otherwise = mempty

-- | A block scalar with the text needs an indentation indicator, because its
-- first line with content starts with a space or a tab. YAML 1.2 does not
-- need the indicator for a tab, but libyaml rejects the block scalar without
-- it. Parsers do not agree on the meaning of the indicator at the top level,
-- so a caller there writes such a text with quotes.
needsIndentIndicator :: T.Text -> Bool
needsIndentIndicator t = case T.uncons (T.dropWhile (== '\n') t) of
  Just (c, _) -> c == ' ' || c == '\t'
  Nothing -> False

-- | A line of a block scalar. An empty line gets no indentation.
line :: Int -> T.Text -> B.Builder
line indent l
  | T.null l = "\n"
  | otherwise = "\n" <> spaces indent <> B.fromText l

-- | A tag in the shortest form that reads back as the same tag. A tag that no
-- text can hold, e.g. an empty tag, becomes the non-specific tag @!@.
--
-- A global tag that is not a valid URI needs the directive of
-- 'tagDirective' in its document.
tagText :: T.Text -> B.Builder
tagText tag
  | T.null tag = "!"
  | Just suffix <- textStripPrefix coreTagPrefix tag
  , not (T.null suffix) =
      "!!" <> shorthand suffix
  | Just suffix <- textStripPrefix "!" tag
  , not (T.null suffix) =
      "!" <> shorthand suffix
  | Just (c, suffix) <- T.uncons tag
  , not (isVerbatim tag) =
      if T.null suffix then "!" else handleText c <> shorthand suffix
  | otherwise = "!<" <> B.fromText tag <> ">"
  where
    -- The text of a tag suffix. A character that the form does not allow gets
    -- a %XX escape, which the parser decodes. So does #, which YAML allows,
    -- but libyaml, PyYAML and go-yaml reject.
    shorthand :: T.Text -> B.Builder
    shorthand =
      T.foldr
        ( \x b ->
            (if x /= '#' && asciiChar isTagChar x then B.fromChar x else percentEscape x)
              <> b
        )
        mempty

-- | The handles for the tags of the node and the nodes in it that are not
-- valid URIs, each once.
tagHandles :: Node -> [Char]
tagHandles n0 = nubOrd (go n0 [])
  where
    go :: Node -> [Char] -> [Char]
    go n acc =
      (case n.props.tag of Tag t -> maybe id (:) (tagHandle t); _ -> id) $
        case n.content of
          SequenceContent _ xs -> foldr go acc xs
          MappingContent _ kvs -> foldr (\(k, v) -> go k . go v) acc kvs
          _ -> acc

    -- The character whose handle a tag needs, if the tag needs a directive.
    tagHandle :: T.Text -> Maybe Char
    tagHandle tag = case T.uncons tag of
      Just (c, suffix)
        | c /= '!'
        , not (T.null suffix)
        , not (textIsPrefixOf coreTagPrefix tag)
        , not (isVerbatim tag) ->
            Just c
      _ -> Nothing

-- | The @%TAG@ directive of the handle for the tags that start with the
-- character, with the line break. The prefix is always an escape, because
-- e.g. a @#@ after a space starts a comment.
tagDirective :: Char -> B.Builder
tagDirective c = "%TAG " <> handleText c <> " " <> percentEscape c <> "\n"

handleText :: Char -> B.Builder
handleText c = "!t" <> B.fromText (T.pack (showHex (ord c) "")) <> "!"

-- | A global tag that a verbatim tag holds as it is. A % in a verbatim tag
-- starts an escape, and libyaml, PyYAML and go-yaml reject a #, so a tag with
-- a % or a # goes in a shorthand tag, with escapes.
isVerbatim :: T.Text -> Bool
isVerbatim tag =
  hasScheme && T.all (\c -> c /= '%' && c /= '#' && asciiChar isUriChar c) tag
  where
    hasScheme :: Bool
    hasScheme = case T.break (== ':') tag of
      (scheme, rest) -> case T.uncons scheme of
        Just (c, cs) ->
          isAscii c
            && isAlpha c
            && T.all (\x -> isAscii x && (isAlphaNum x || elem @[] x "+-.")) cs
            && not (T.null rest)
        Nothing -> False

-- | The %XX escapes of the UTF-8 bytes of a character.
percentEscape :: Char -> B.Builder
percentEscape c =
  mconcat
    [ B.fromText (T.pack ('%' : upperHex percentDigits (fromIntegral w)))
    | w <- BS.unpack (T.encodeUtf8 (T.singleton c))
    ]

-- | The number in uppercase hex digits, with zeros in front up to the given
-- number of digits.
upperHex :: Int -> Int -> String
upperHex k i = let s = map toUpper (showHex i "") in replicate (k - length s) '0' ++ s

-- | c-printable without the line breaks and the byte order mark.
isPrintable :: Char -> Bool
isPrintable c
  | c < ' ' = False
  | c <= '~' = True
  | c < '\xA0' = False
  | c == '\xFEFF' = False
  | c >= '\xD800' && c <= '\xDFFF' = False
  | c == '\xFFFE' || c == '\xFFFF' = False
  | otherwise = True

-- | A printable character that needs no escape in a scalar. YAML 1.1 reads
-- U+2028 and U+2029 as line breaks, so they get escapes too.
isScalarChar :: Char -> Bool
-- The guards for ASCII come first. Without them, the encode benchmark of
-- the long texts is slower.
isScalarChar c
  | c < ' ' = False
  | c <= '~' = True
  | otherwise = isPrintable c && c /= '\x2028' && c /= '\x2029'

spaces :: Int -> B.Builder
spaces k = B.fromText (T.replicate k " ")