packages feed

figment-0.2.0.0: src/Figment/Internal/ValueParser.hs

-- | Parsing untyped text (environment variables, CLI arguments) into
-- structured values, with syntax resembling TOML (as Rust figment's
-- @Env@ does):
--
-- * @true@ / @false@ are booleans
-- * integers and decimals (@-12@, @3.5@, @1e3@) are exact numbers;
--   @inf@, @-inf@, @nan@ are the non-finite numbers
-- * @\"double\"@ and @'single'@ quoted text is a string (quotes removed)
-- * @[a, b, c]@ is an array and @{k = v, k2 = v2}@ a dictionary; their
--   elements follow the same rules, and bare words inside them are
--   strings
-- * anything else, including malformed structured text, is the string
--   itself, verbatim
--
-- The top-level result always records the original text in 'tagRaw',
-- so a 'Text' field still receives @007@ rather than @7@.
module Figment.Internal.ValueParser
  ( parseValue
  , parseNumber
  ) where

import Data.Char (isDigit, isSpace)
import qualified Data.Map.Strict as Map
import Data.Scientific (scientific)
import Data.Text (Text)
import qualified Data.Text as Text
import qualified Data.Vector as Vector

import Figment.Error
import Figment.Limits
import Figment.Value

-- | Parse one untyped text value. Fails only when structured text is
-- nested beyond the depth limit; any syntax problem falls back to the
-- string.
parseValue :: Limits -> Text -> Either ConfigError Value
parseValue lim raw = case top of
  Left (Limit e) -> Left e
  Left NoParse -> Right (rawTagged (String raw))
  Right n -> Right (rawTagged n)
  where
    trimmed = Text.strip raw
    rawTagged n = Value untagged {tagRaw = Just raw} n
    top
      | Text.null trimmed = Right (String raw)
      | otherwise = case Text.head trimmed of
          c | c == '[' || c == '{' || c == '"' || c == '\'' ->
                case structured lim 1 trimmed of
                  Right (v, rest) | Text.null (Text.strip rest) -> Right (valueNode v)
                  Right _ -> Left NoParse
                  Left e -> Left e
          _ -> Right (scalarNode raw trimmed)

data Failure = NoParse | Limit ConfigError

-- | A bare scalar: boolean, number, or the text itself.
scalarNode :: Text -> Text -> Node
scalarNode original t
  | t == "true" = Bool True
  | t == "false" = Bool False
  | Just n <- parseNumber t = Num n
  | otherwise = String original

type P a = Either Failure (a, Text)

structured :: Limits -> Int -> Text -> P Value
structured lim depth t0 = case Text.uncons t of
  Just ('[', rest) -> do
    guardDepth
    (xs, rest') <- elements rest
    pure (mkValue (Array (Vector.fromList xs)), rest')
  Just ('{', rest) -> do
    guardDepth
    (kvs, rest') <- pairs rest
    pure (mkValue (Dict (Map.fromList kvs)), rest')
  Just ('"', rest) -> first' string (quotedDouble "" rest)
  Just ('\'', rest) -> first' string (quotedSingle rest)
  Just _ ->
    let (tok, rest) = Text.break (`elem` (",]}" :: String)) t
        tok' = Text.strip tok
    in if Text.null tok'
         then Left NoParse
         else Right (mkValue (scalarNode tok' tok'), rest)
  Nothing -> Left NoParse
  where
    t = Text.stripStart t0
    guardDepth
      | depth > maxDepth lim = Left (Limit (configError (DepthExceeded (maxDepth lim))))
      | otherwise = Right ()

    elements s0 =
      let s = Text.stripStart s0
      in case Text.uncons s of
           Just (']', rest) -> Right ([], rest)
           _ -> go [] s
      where
        go acc s = do
          (v, rest) <- structured lim (depth + 1) s
          let rest' = Text.stripStart rest
          case Text.uncons rest' of
            Just (',', more) ->
              let more' = Text.stripStart more
              in case Text.uncons more' of
                   Just (']', fin) -> Right (reverse (v : acc), fin)
                   _ -> go (v : acc) more'
            Just (']', fin) -> Right (reverse (v : acc), fin)
            _ -> Left NoParse

    pairs s0 =
      let s = Text.stripStart s0
      in case Text.uncons s of
           Just ('}', rest) -> Right ([], rest)
           _ -> go [] s
      where
        go acc s = do
          (k, rest) <- key s
          rest1 <- case Text.uncons (Text.stripStart rest) of
            Just (c, r) | c == '=' || c == ':' -> Right r
            _ -> Left NoParse
          (v, rest2) <- structured lim (depth + 1) rest1
          let rest3 = Text.stripStart rest2
          case Text.uncons rest3 of
            Just (',', more) ->
              let more' = Text.stripStart more
              in case Text.uncons more' of
                   Just ('}', fin) -> Right (reverse ((k, v) : acc), fin)
                   _ -> go ((k, v) : acc) more'
            Just ('}', fin) -> Right (reverse ((k, v) : acc), fin)
            _ -> Left NoParse

    key s = case Text.uncons (Text.stripStart s) of
      Just ('"', rest) -> quotedDouble "" rest
      Just ('\'', rest) -> quotedSingle rest
      _ ->
        let (k, rest) = Text.span (\c -> not (isSpace c) && c `notElem` ("=:,{}[]" :: String)) (Text.stripStart s)
        in if Text.null k then Left NoParse else Right (k, rest)

    first' f r = fmap (\(a, rest) -> (f a, rest)) r

quotedDouble :: Text -> Text -> P Text
quotedDouble acc s = case Text.uncons s of
  Nothing -> Left NoParse
  Just ('"', rest) -> Right (acc, rest)
  Just ('\\', rest) -> case Text.uncons rest of
    Just (c, rest') -> case c of
      'n' -> quotedDouble (Text.snoc acc '\n') rest'
      't' -> quotedDouble (Text.snoc acc '\t') rest'
      'r' -> quotedDouble (Text.snoc acc '\r') rest'
      _ -> quotedDouble (Text.snoc acc c) rest'
    Nothing -> Left NoParse
  Just (c, rest) -> quotedDouble (Text.snoc acc c) rest

quotedSingle :: Text -> P Text
quotedSingle s =
  let (body, rest) = Text.break (== '\'') s
  in case Text.uncons rest of
       Just ('\'', rest') -> Right (body, rest')
       _ -> Left NoParse

-- | Exact number syntax: @[+-]?digits(.digits)?([eE][+-]?digits)?@, or
-- @inf@ / @+inf@ / @-inf@ / @nan@.
parseNumber :: Text -> Maybe Number
parseNumber t = case t of
  "inf" -> Just PosInfinity
  "+inf" -> Just PosInfinity
  "-inf" -> Just NegInfinity
  "nan" -> Just NaN
  "+nan" -> Just NaN
  "-nan" -> Just NaN
  _ -> do
    let (neg, body) = case Text.uncons t of
          Just ('-', r) -> (True, r)
          Just ('+', r) -> (False, r)
          _ -> (False, t)
        (intPart, r1) = Text.span isDigit body
    if Text.null intPart then Nothing else pure ()
    (fracPart, r2) <- case Text.uncons r1 of
      Just ('.', r) ->
        let (f, r') = Text.span isDigit r
        in if Text.null f then Nothing else Just (f, r')
      _ -> Just ("", r1)
    expo <- case Text.uncons r2 of
      Nothing -> Just 0
      Just (e, r) | e == 'e' || e == 'E' ->
        let (sgn, ds) = case Text.uncons r of
              Just ('-', d) -> (-1, d)
              Just ('+', d) -> (1, d)
              _ -> (1, r)
        in if Text.null ds || not (Text.all isDigit ds) || Text.length ds > 9
             then Nothing
             else Just (sgn * read (Text.unpack ds) :: Int)
      _ -> Nothing
    let coeff = read (Text.unpack (intPart <> fracPart)) :: Integer
        signed = if neg then negate coeff else coeff
    pure (Finite (scientific signed (expo - Text.length fracPart)))