packages feed

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

{-# OPTIONS_HADDOCK not-home #-}

-- | The core schema of YAML 1.2.2.
--
-- This module is intended for internal use only, and may change without warning
-- in subsequent releases.
module Yamlet.Internal.Schema
  ( resolvePlain
  , resolveTagged
  , resolvePlainExact
  , resolveTaggedExact
  , startsNumber
  , isPlainString
  , isPlainSafe
  , isPlainPortable
  , isYaml11Bool
  , isYaml11NonString
  , isYaml11Timestamp
  , exponentOutOfRange
  ) where

import Control.Monad
import Data.Bifunctor
import Data.Char
import Data.Maybe
import Data.Scientific qualified as Sci
import Data.Text qualified as T
import Data.Time.Calendar

import Yamlet.Internal.Emit
import Yamlet.Internal.Utils
import Yamlet.Value

-- | The value of a plain scalar without a tag. Not every such scalar is a
-- string, e.g. @null@, @true@, @12@, @0x1F@ and @1.5e3@ are not. Quoted and
-- block scalars are always strings.
--
-- A float whose exponent in scientific notation is beyond the range from
-- -1000 to 1000, e.g. @1e1001@, @10e1000@ or @1e-1001@, becomes infinity
-- or zero, as a double does. The decoders reject such a number, because its
-- value is not exact.
--
-- >>> map resolvePlain ["", "true", "0x1F", "1.5e3", ".inf", "yes", "9.10.3"]
-- [Null,Bool True,Int 31,Float (Finite 1500.0),Float Infinity,String "yes",String "9.10.3"]
resolvePlain :: T.Text -> Value
resolvePlain = either id id . resolvePlainExact

-- | The value of a scalar with the given resolved tag, e.g.
-- @tag:yaml.org,2002:int@. Return 'Nothing' if the text is not valid for a
-- tag of the core schema. A scalar with another tag is a string.
--
-- A float beyond the limit becomes infinity or zero, as in 'resolvePlain'.
--
-- >>> [resolveTagged floatTag "1", resolveTagged intTag "abc", resolveTagged seqTag "x", resolveTagged "!point" "1"]
-- [Just (Float (Finite 1.0)),Nothing,Nothing,Just (String "1")]
resolveTagged :: T.Text -> T.Text -> Maybe Value
resolveTagged tag t = either id id <$> resolveTaggedExact tag t

-- | The value of a plain scalar, 'Left' if the value is not exact.
resolvePlainExact :: T.Text -> Either Value Value
resolvePlainExact t = case T.uncons t of
  Nothing -> Right Null
  Just (c, _)
    | c == '~' || c == 'n' || c == 'N' -> Right $ if isNull t then Null else String t
    | c == 't' || c == 'T' || c == 'f' || c == 'F' ->
        Right $ maybe (String t) Bool (readBool t)
    | startsNumber c -> case readInt t of
        Just i -> Right (Int i)
        Nothing -> maybe (Right (String t)) (bimap Float Float) (readFloat t)
    | otherwise -> Right (String t)

-- | A plain scalar that starts with the character can be a number. Only such
-- a scalar can have a value that is not exact.
startsNumber :: Char -> Bool
startsNumber c = isDigit c || c == '-' || c == '+' || c == '.'

-- | The value of a scalar with a tag, 'Left' if the value is not exact.
resolveTaggedExact :: T.Text -> T.Text -> Maybe (Either Value Value)
resolveTaggedExact tag t
  | tag == strTag = Just . Right $ String t
  | tag == nullTag = if isNull t then Just (Right Null) else Nothing
  | tag == boolTag = Right . Bool <$> readBool t
  | tag == intTag = Right . Int <$> readInt t
  | tag == floatTag = bimap Float Float <$> readFloat t
  | tag == seqTag || tag == mapTag = Nothing
  | otherwise = Just . Right $ String t

-- | A plain scalar with the text is a string, e.g. @9.10.3@ is a string, but
-- @9.10@ and @true@ are not. The check ignores the syntax, so e.g. @a: b@
-- passes. For both checks, use 'isPlainSafe'.
--
-- >>> map isPlainString ["9.10.3", "9.10", "true", "a: b"]
-- [True,False,False,True]
isPlainString :: T.Text -> Bool
isPlainString t = case resolvePlain t of
  String _ -> True
  _ -> False

-- | The string reads back as the same string if it is a plain scalar in the
-- block style, as a value or as a key. A key without @?@ must also have at
-- most 1024 characters, which the check does not count. In a flow collection
-- the characters @,[]{}@ need quotes too, so the check does not apply there.
--
-- >>> map isPlainSafe ["a:b", "a: b", "- a", "a #b", "9.10"]
-- [True,False,False,False,False]
isPlainSafe :: T.Text -> Bool
isPlainSafe t = plainSyntax False t && isPlainString t

-- | As 'isPlainSafe', and common YAML 1.1 parsers also read the plain scalar
-- as a string, e.g. not @yes@ as a boolean or @12:30@ as a number. The
-- encoder writes a string without quotes only if it passes this check.
--
-- >>> map isPlainPortable ["a:b", "yes", "12:30", "2024-01-01", "9.10.3"]
-- [True,False,False,False,True]
isPlainPortable :: T.Text -> Bool
isPlainPortable t = isPlainSafe t && not (isYaml11NonString t)

isNull :: T.Text -> Bool
isNull t = T.null t || t == "~" || t == "null" || t == "Null" || t == "NULL"

readBool :: T.Text -> Maybe Bool
readBool = \case
  "true" -> Just True
  "True" -> Just True
  "TRUE" -> Just True
  "false" -> Just False
  "False" -> Just False
  "FALSE" -> Just False
  _ -> Nothing

-- | A word that YAML 1.1 reads as a boolean, but YAML 1.2 as a string, e.g.
-- @yes@ or @off@.
isYaml11Bool :: T.Text -> Bool
isYaml11Bool t =
  t `elem` ["y", "Y", "yes", "Yes", "YES", "on", "On", "ON"]
    || t `elem` ["n", "N", "no", "No", "NO", "off", "Off", "OFF"]

-- | A common YAML 1.1 parser reads a plain scalar with the text as a value
-- that is not a string, e.g. the boolean @yes@, the base-60 number @12:30@ or
-- the date @2024-01-01@. The patterns cover what PyYAML, Ruby's Psych and
-- go-yaml v2, which Kubernetes uses, accept. The YAML 1.1 types themselves
-- are not enough: the parsers accept more, e.g. @1,000@ in Psych and @0X1F@ in
-- go-yaml v2, and the type of floats accepts too much, e.g. @1.2.3@.
isYaml11NonString :: T.Text -> Bool
isYaml11NonString t = case T.uncons t of
  Nothing -> True
  Just (c, _)
    -- A symbol in Psych, which its safe loader rejects.
    | c == ':' -> T.compareLength t 1 == GT
    | isDigit c || c == '-' || c == '+' || c == '.' ->
        matches (alt [int, float, timestamp]) t || matches goNumber (T.filter (/= '_') t)
    -- Psych merges a quoted << too, only !!str << stays a key there.
    | otherwise ->
        t `elem` ["y", "Y", "n", "N", "~", "<<", "="]
          -- Psych ignores the case of these words.
          || ( T.compareLength t 5 /= GT
                 && T.toLower t `elem` ["yes", "no", "true", "false", "on", "off", "null"]
             )
  where
    matches :: (T.Text -> [T.Text]) -> T.Text -> Bool
    matches m s = any T.null (m s)

    int :: T.Text -> [T.Text]
    int =
      sign
        >=> alt
          [ str "0b" >=> some (separatorOr (`elem` ['0', '1']))
          , one (== '0') >=> some (separatorOr isOctDigit)
          , one (== '0')
          , nonZero >=> many (separatorOr isDigit)
          , str "0x" >=> some (separatorOr isHexDigit)
          , digit >=> many (underscoreOr isDigit) >=> sexagesimal
          ]

    float :: T.Text -> [T.Text]
    float =
      sign
        >=> alt
          [ digit
              >=> many (separatorOr isDigit)
              >=> one (== '.')
              >=> many (underscoreOr isDigit)
              >=> opt exponentPart
          , one (== '.') >=> some (underscoreOr isDigit) >=> opt exponentPart
          , one (== '.') >=> exponentPart
          , digit
              >=> many (underscoreOr isDigit)
              >=> sexagesimal
              >=> one (== '.')
              >=> many (underscoreOr isDigit)
          , one (== '.') >=> caseless "inf"
          , one (== '.') >=> caseless "nan"
          ]

    timestamp :: T.Text -> [T.Text]
    timestamp =
      alt
        [ digits 4 >=> one (== '-') >=> oneOrTwoDigits >=> one (== '-') >=> oneOrTwoDigits
        , opt (one (== '-'))
            >=> digits 4
            >=> one (== '-')
            >=> oneOrTwoDigits
            >=> one (== '-')
            >=> oneOrTwoDigits
            >=> alt [one (`elem` ['T', 't']), some blank]
            >=> oneOrTwoDigits
            >=> one (== ':')
            >=> digits 2
            >=> one (== ':')
            >=> digits 2
            >=> opt (one (== '.') >=> many digit)
            >=> opt
              ( many blank
                  >=> alt
                    [ one (== 'Z')
                    , one (`elem` ['+', '-'])
                        >=> oneOrTwoDigits
                        >=> opt (opt (one (== ':')) >=> digits 2)
                    ]
              )
        ]

    -- The numbers of go-yaml v2, which removes the underscores first: the
    -- integers of Go, a float whose dot and sign of the exponent are
    -- optional, and a binary integer with its sign after "0b", e.g. 0b-1.
    goNumber :: T.Text -> [T.Text]
    goNumber =
      alt
        [ sign
            >=> alt
              [ one (== '0')
                  >=> one (`elem` ['x', 'X'])
                  >=> some (one isHexDigit)
              , one (== '0')
                  >=> one (`elem` ['o', 'O'])
                  >=> some (one isOctDigit)
              , one (== '0')
                  >=> one (`elem` ['b', 'B'])
                  >=> some (one (`elem` ['0', '1']))
              , alt
                  [ one (== '.') >=> some digit
                  , some digit >=> opt (one (== '.') >=> many digit)
                  ]
                  >=> opt
                    ( one (`elem` ['e', 'E'])
                        >=> opt (one (`elem` ['+', '-']))
                        >=> some digit
                    )
              ]
        , str "0b" >=> one (`elem` ['+', '-']) >=> some (one (`elem` ['0', '1']))
        ]

    -- Each matcher gives the rests of the text after all its possible
    -- matches, so the patterns backtrack as the regular expressions of the
    -- parsers do and each one matches its regular expression. A parser such as
    -- attoparsec does not backtrack into an optional or repeated part, e.g.
    -- [0-5]?[0-9] would take the 5 of 1:5 and then find no digit.
    one :: (Char -> Bool) -> T.Text -> [T.Text]
    one p s = case T.uncons s of
      Just (x, rest) | p x -> [rest]
      _ -> []

    str :: T.Text -> T.Text -> [T.Text]
    str prefix s = maybe [] pure (textStripPrefix prefix s)

    alt :: [T.Text -> [T.Text]] -> T.Text -> [T.Text]
    alt ms s = concatMap ($ s) ms

    opt :: (T.Text -> [T.Text]) -> T.Text -> [T.Text]
    opt m s = s : m s

    -- The rests of each step go in front of the rests that follow, because
    -- s : (m s >>= many m) appends once per step and takes quadratic time.
    many :: (T.Text -> [T.Text]) -> T.Text -> [T.Text]
    many m s0 = go s0 []
      where
        go :: T.Text -> [T.Text] -> [T.Text]
        go s rests = s : foldr go rests (m s)

    some :: (T.Text -> [T.Text]) -> T.Text -> [T.Text]
    some m = m >=> many m

    sign :: T.Text -> [T.Text]
    sign = opt (one (`elem` ['+', '-']))

    digit :: T.Text -> [T.Text]
    digit = one isDigit

    digits :: Int -> T.Text -> [T.Text]
    digits k = foldr (>=>) pure (replicate k digit)

    oneOrTwoDigits :: T.Text -> [T.Text]
    oneOrTwoDigits = digit >=> opt digit

    nonZero :: T.Text -> [T.Text]
    nonZero = one (\x -> isDigit x && x /= '0')

    underscoreOr :: (Char -> Bool) -> T.Text -> [T.Text]
    underscoreOr p = one (\x -> x == '_' || p x)

    -- Psych also allows commas in numbers, e.g. 1,000.
    separatorOr :: (Char -> Bool) -> T.Text -> [T.Text]
    separatorOr p = one (\x -> x == '_' || x == ',' || p x)

    caseless :: T.Text -> T.Text -> [T.Text]
    caseless w s =
      [rest | let (prefix, rest) = T.splitAt (T.length w) s, T.toLower prefix == w]

    sexagesimal :: T.Text -> [T.Text]
    sexagesimal = some (one (== ':') >=> opt (one (`elem` ['0' .. '5'])) >=> digit)

    exponentPart :: T.Text -> [T.Text]
    exponentPart = one (`elem` ['e', 'E']) >=> one (`elem` ['+', '-']) >=> some digit

    blank :: T.Text -> [T.Text]
    blank = one (`elem` [' ', '\t'])

-- | A common YAML 1.1 parser reads a plain scalar with the text as a
-- timestamp and can build it. PyYAML has the years from 1 to 9999 of Python,
-- and it rejects the hour 24, a leap second and a time zone of 24 hours.
-- Psych reads the hour 24 and a leap second as a later time.
--
-- >>> map isYaml11Timestamp ["2024-01-01", "2024-01-01T12:30:00Z", "0000-01-01", "2016-12-31T23:59:60Z", "12:30"]
-- [True,True,False,False,False]
isYaml11Timestamp :: T.Text -> Bool
isYaml11Timestamp t =
  isYaml11NonString t && case T.splitOn "-" date of
    [y, m, d]
      | T.length y == 4
      , all (\ds -> not (T.null ds) && T.all isDigit ds) [y, m, d] ->
          let year = digitsValue 10 y
          in year >= 1
               && year <= 9999
               && isJust (fromGregorianValid year (number m) (number d))
               && validTime (T.dropWhile isTimeSeparator rest)
    _ -> False
  where
    (date, rest) = T.break isTimeSeparator t

    isTimeSeparator :: Char -> Bool
    isTimeSeparator c = c == 'T' || c == 't' || c == ' ' || c == '\t'

    number :: T.Text -> Int
    number = fromInteger . digitsValue 10

    -- The time, with the hours, the minutes and the seconds of the pattern of
    -- 'isYaml11NonString'.
    validTime :: T.Text -> Bool
    validTime s
      | T.null s = True
      | otherwise = case T.splitOn ":" (T.takeWhile (\c -> isDigit c || c == ':') s) of
          h : _ : sec : _ ->
            number h < 24
              && number (T.take 2 sec) < 60
              && validZone
                (T.dropWhile (\c -> isDigit c || c `elem` [':', '.', ' ', '\t']) s)
          _ -> False

    -- The hours of a zone are the digits before the last two, unless the
    -- zone has a colon or at most two digits.
    validZone :: T.Text -> Bool
    validZone z = case T.uncons z of
      Just (c, offset)
        | c == '+' || c == '-' ->
            let hours = T.takeWhile isDigit offset
                h = if T.compareLength hours 2 == GT then T.dropEnd 2 hours else hours
            in number h < 24
      _ -> True

-- | [-+]?[0-9]+, 0o[0-7]+ or 0x[0-9a-fA-F]+.
readInt :: T.Text -> Maybe Integer
readInt t
  | Just ds <- textStripPrefix "0o" t = digits 8 isOctDigit ds
  | Just ds <- textStripPrefix "0x" t = digits 16 isHexDigit ds
  | Just ds <- textStripPrefix "-" t = negate <$> digits 10 isDigit ds
  | Just ds <- textStripPrefix "+" t = digits 10 isDigit ds
  | otherwise = digits 10 isDigit t
  where
    digits :: Integer -> (Char -> Bool) -> T.Text -> Maybe Integer
    digits radix valid ds
      | not (T.null ds) && T.all valid ds = Just $ digitsValue radix ds
      | otherwise = Nothing

-- | The value of the digits in the radix. A multiplication for each digit
-- takes quadratic time in the number of digits, so the halves of a long text
-- are read apart.
digitsValue :: Integer -> T.Text -> Integer
digitsValue radix t0 = go (T.length t0) t0
  where
    go :: Int -> T.Text -> Integer
    go n t
      | n <= maxFoldDigits =
          T.foldl' (\acc d -> acc * radix + toInteger (digitToInt d)) 0 t
      | otherwise =
          let k = n `div` 2
              (hi, lo) = T.splitAt (n - k) t
          in go (n - k) hi * radix ^ k + go k lo

    -- Up to about 20 digits, one fold is faster than a split, measured with
    -- GHC 9.10.3 for numbers from 60 to 100000 digits.
    maxFoldDigits :: Int
    maxFoldDigits = 20

-- | The limit of the exponent of a float in scientific notation, i.e. the
-- exponent of its first digit that is not zero. The limit applies to the
-- value, not to the text, so every value that the decoder gives reads back
-- after the encoder writes it.
--
-- A t'Data.Scientific.Scientific' keeps the exponent apart from the
-- coefficient, but its conversion to an 'Integer', e.g. with 'truncate',
-- computes every digit. With this limit, the integer has at most 1001
-- digits. Without a limit, a short input such as @1e999999999@ gives an
-- integer of about 400 MiB. The limit covers the whole range of 'Double',
-- from about 5e-324 to 1.8e308.
maxExponent :: Integer
maxExponent = 1000

-- | The error for a number beyond 'maxExponent'.
exponentOutOfRange :: String
exponentOutOfRange =
  "the exponent of the number is out of the range from "
    ++ show (negate maxExponent)
    ++ " to "
    ++ show maxExponent

-- | [-+]?(\.[0-9]+|[0-9]+(\.[0-9]*)?)([eE][-+]?[0-9]+)?, [-+]?\.inf or \.nan
-- in one of three capitalizations. The value is 'Left' if it is not exact.
readFloat :: T.Text -> Maybe (Either FloatValue FloatValue)
readFloat t0 = case t0 of
  ".nan" -> Just (Right NaN)
  ".NaN" -> Just (Right NaN)
  ".NAN" -> Just (Right NaN)
  _ -> case T.uncons t0 of
    Just ('-', t) -> bimap negateFloat negateFloat <$> unsigned t
    Just ('+', t) -> unsigned t
    _ -> unsigned t0
  where
    unsigned :: T.Text -> Maybe (Either FloatValue FloatValue)
    unsigned t
      | t == ".inf" || t == ".Inf" || t == ".INF" = Just (Right Infinity)
      | otherwise =
          let (int, rest) = T.span isDigit t
              (frac, rest') = case T.uncons rest of
                Just ('.', r) -> T.span isDigit r
                _ -> ("", rest)
              hasDot = textIsPrefixOf "." rest
          in if
               | T.null int && T.null frac -> Nothing
               | not (T.null int) || hasDot -> do
                   ex <- exponent_ rest'
                   Just $ decimal (int <> frac) (ex - toInteger (T.length frac))
               | otherwise -> Nothing

    -- The decimal digits times a power of 10, with the exponent that the text
    -- of the float has. A value beyond the limit of 'maxExponent' gives
    -- infinity or zero, which are not exact.
    decimal :: T.Text -> Integer -> Either FloatValue FloatValue
    decimal ds e
      | c == 0 = Right (Finite 0)
      | abs leading > maxExponent = Left (if leading > 0 then Infinity else Finite 0)
      | otherwise = Right $ Finite (Sci.scientific c (fromInteger e))
      where
        -- The exponent of the first digit that is not zero.
        leading :: Integer
        leading = e + toInteger (T.length (T.dropWhile (== '0') ds)) - 1

        c :: Integer
        c = digitsValue 10 ds

    negateFloat :: FloatValue -> FloatValue
    negateFloat = \case
      Finite s
        | s == 0 -> NegativeZero
        | otherwise -> Finite (negate s)
      NegativeZero -> Finite 0
      Infinity -> NegativeInfinity
      NegativeInfinity -> Infinity
      NaN -> NaN

    exponent_ :: T.Text -> Maybe Integer
    exponent_ t = case T.uncons t of
      Nothing -> Just 0
      Just (e, r)
        | e == 'e' || e == 'E' ->
            let (sign, ds) = case T.uncons r of
                  Just ('-', d) -> (negate, d)
                  Just ('+', d) -> (id, d)
                  _ -> (id, r)
            in if not (T.null ds) && T.all isDigit ds
                 then Just . sign $ digitsValue 10 ds
                 else Nothing
        | otherwise -> Nothing