packages feed

nova-nix-0.9.0.0: src/Nix/Json.hs

-- | JSON as upstream Nix writes and reads it.
--
-- Upstream serializes through nlohmann::json, so @builtins.toJSON@, the
-- @__json@ environment entry of a structured-attributes derivation and the
-- @.attrs.json@ file its builder reads are all nlohmann's @dump()@ with no
-- indentation.  A derivation's @__json@ is hashed into its store path, so
-- 'renderJson' is identity, not presentation: every byte of it is
-- nlohmann's.  'parseJson' is the way back, for the builder, which reads
-- @__json@ out of a @.drv@ the way upstream's @nlohmann::json::parse@ does.
module Nix.Json
  ( -- * Values
    Json (..),

    -- * Rendering
    renderJson,
    renderJsonWith,
    formatJsonFloat,

    -- * Parsing
    IntegerRange (..),
    parseJson,
    parseJsonWith,
  )
where

import Data.ByteString (ByteString)
import qualified Data.ByteString.Builder as BB
import qualified Data.ByteString.Lazy as BL
import Data.Char (chr, isDigit, ord)
import Data.Int (Int64)
import Data.List (intersperse)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Data.Word (Word64)
import Numeric (floatToDigits, showHex)
import Text.Read (readMaybe)

-- ---------------------------------------------------------------------------
-- Values
-- ---------------------------------------------------------------------------

-- | A JSON value.  Strings are 'Text' because nlohmann refuses a string
-- that is not UTF-8 in both directions, so bytes reach JSON decoded once,
-- at the boundary that produced them.  Integers and floats stay apart, as
-- nlohmann keeps them: @1@ and @1.0@ render differently, and a builder's
-- @.attrs.sh@ treats them differently.
data Json
  = JsonNull
  | JsonBool !Bool
  | -- | Rendered in decimal.  A parse produces one only in its
    -- 'IntegerRange', signed 64-bit below zero in both.
    JsonInt !Integer
  | -- | A non-finite float renders as @null@, as nlohmann writes one.
    JsonFloat !Double
  | JsonString !Text
  | JsonArray ![Json]
  | -- | Members render in byte order of their names, the order of the
    -- @std::map@ nlohmann keeps them in.  'Text' compares by code point,
    -- which on UTF-8 is the same order.
    JsonObject !(Map Text Json)
  deriving (Eq, Show)

-- ---------------------------------------------------------------------------
-- Rendering
-- ---------------------------------------------------------------------------

-- | nlohmann's @dump()@: no whitespace, members in name order, strings
-- escaped as 'escapeJsonString' describes, floats as 'formatJsonFloat'.
renderJson :: Json -> ByteString
renderJson = renderJsonWith id

-- | 'renderJson' with every string, member names included, passed through
-- a rewrite before it is escaped.  Upstream's builder rewrites the rendered
-- text instead (@rewriteStrings@ over @json.dump()@).  The two agree while
-- neither side of a rewrite holds a character JSON escapes; rewriting
-- first is what keeps the result JSON when a replacement does hold one,
-- as a Windows store directory holds backslashes.
renderJsonWith :: (Text -> Text) -> Json -> ByteString
renderJsonWith rewrite = BL.toStrict . BB.toLazyByteString . render
  where
    render JsonNull = "null"
    render (JsonBool True) = "true"
    render (JsonBool False) = "false"
    render (JsonInt n) = BB.integerDec n
    render (JsonFloat d)
      | isNaN d || isInfinite d = "null"
      | otherwise = TE.encodeUtf8Builder (formatJsonFloat d)
    render (JsonString s) = quoted s
    render (JsonArray items) = BB.char7 '[' <> commaSeparated (map render items) <> BB.char7 ']'
    render (JsonObject members) =
      BB.char7 '{' <> commaSeparated (map member (Map.toAscList members)) <> BB.char7 '}'
    member (name, value) = quoted name <> BB.char7 ':' <> render value
    quoted s = BB.char7 '"' <> escapeJsonString (rewrite s) <> BB.char7 '"'
    commaSeparated = mconcat . intersperse (BB.char7 ',')

-- | nlohmann's @dump_escaped@ without @ensure_ascii@: two-character escapes
-- for the quote, the backslash, backspace, form feed, newline, carriage
-- return and tab; @\\u00xx@ in lowercase hex for the other control
-- characters; every other character as its UTF-8 bytes, DEL and @/@
-- included.
escapeJsonString :: Text -> BB.Builder
escapeJsonString s = case T.break needsEscape s of
  (plain, rest) ->
    TE.encodeUtf8Builder plain <> case T.uncons rest of
      Nothing -> mempty
      Just (c, more) -> escapeChar c <> escapeJsonString more
  where
    needsEscape c = c == '"' || c == '\\' || c < ' '
    escapeChar '"' = "\\\""
    escapeChar '\\' = "\\\\"
    escapeChar '\b' = "\\b"
    escapeChar '\f' = "\\f"
    escapeChar '\n' = "\\n"
    escapeChar '\r' = "\\r"
    escapeChar '\t' = "\\t"
    escapeChar c = "\\u00" <> BB.word8HexFixed (fromIntegral (ord c))

-- | Format a finite float exactly as nlohmann's @to_chars@ does: shortest
-- round-trip digits, laid out as plain decimal only while the decimal
-- point lands within positions 'jsonMinPointPos'..'jsonMaxPointPos', a
-- @.0@ suffix on integral values, and otherwise @d.ddde+XX@ with a signed
-- exponent of at least two digits.  Zero is @0.0@ (sign preserved).
-- Digits come from 'floatToDigits', which is always shortest; nlohmann's
-- grisu2 can emit a longer-than-shortest form for rare values, an accepted
-- divergence.  Non-finite input is the caller's concern (JSON spells it
-- @null@).
formatJsonFloat :: Double -> Text
formatJsonFloat d
  | isNegativeZero d = "-0.0"
  | d == 0 = "0.0"
  | d < 0 = "-" <> formatJsonFloat (negate d)
  | otherwise =
      let (digitList, pointPos) = floatToDigits 10 d
          digits = concatMap show digitList
       in T.pack (jsonFloatLayout digits (length digits) pointPos)

-- | Positional layout of shortest digits with the decimal point at
-- @pointPos@, replicating nlohmann's @format_buffer@ branch by branch.
jsonFloatLayout :: String -> Int -> Int -> String
jsonFloatLayout digits digitCount pointPos
  -- Integral value with the point in plain range: digits, zeros, ".0".
  | digitCount <= pointPos && pointPos <= jsonMaxPointPos =
      digits <> replicate (pointPos - digitCount) '0' <> ".0"
  -- Point falls inside the digit run.
  | 0 < pointPos && pointPos <= jsonMaxPointPos =
      take pointPos digits <> "." <> drop pointPos digits
  -- Small magnitude: leading "0." and padding zeros.
  | jsonMinPointPos < pointPos && pointPos <= 0 =
      "0." <> replicate (negate pointPos) '0' <> digits
  -- Scientific notation.
  | otherwise = mantissa <> "e" <> exponentDigits (pointPos - 1)
  where
    mantissa = case digits of
      [single] -> [single]
      lead : rest -> lead : '.' : rest
      [] -> "0" -- unreachable: a positive double yields at least one digit

-- | nlohmann @format_buffer@ bounds (@kMaxExp@ = double's @digits10@,
-- @kMinExp@): plain decimal only while the decimal point position is in
-- (-4, 15]; everything else is scientific.
jsonMaxPointPos :: Int
jsonMaxPointPos = 15

-- | Lower point-position bound, exclusive.  See 'jsonMaxPointPos'.
jsonMinPointPos :: Int
jsonMinPointPos = -4

-- | nlohmann's @append_exponent@: the sign always, then the magnitude
-- zero-padded to at least two digits (@+05@, @-21@, @+308@).
exponentDigits :: Int -> String
exponentDigits e
  | e < 0 = '-' : padded (negate e)
  | otherwise = '+' : padded e
  where
    padded n
      | n < 10 = '0' : show n
      | otherwise = show n

-- ---------------------------------------------------------------------------
-- Parsing
-- ---------------------------------------------------------------------------

-- | Which integers a parse keeps past the signed 64-bit range.
data IntegerRange
  = -- | nlohmann's own, unsigned 64-bit above zero: what upstream's DOM
    -- parser reads a builder's @__json@ into.
    NlohmannRange
  | -- | The Nix integer range, as @builtins.fromJSON@ reads a document
    -- (json-to-value.cc at 2.25 and after).  Upstream's SAX consumer throws
    -- on reaching a number that fits only unsigned 64 bits, so the error is
    -- the first such number in the document, ahead of any later syntax
    -- error.
    NixRange
  deriving (Eq, Show)

-- | Read one JSON document as nlohmann's @parse@ does: a single value with
-- optional whitespace around it, a leading byte order mark skipped, and
-- nothing after it.  A member name given twice keeps its last value, as
-- nlohmann's DOM parser assigns it.  The input is 'Text' because nlohmann
-- rejects a document that is not UTF-8; decoding it is that check.
parseJson :: Text -> Either Text Json
parseJson = parseJsonWith NlohmannRange

-- | 'parseJson' over a chosen 'IntegerRange'.
parseJsonWith :: IntegerRange -> Text -> Either Text Json
parseJsonWith range input = do
  (value, rest) <- parseValue range (skipSpace (dropByteOrderMark input))
  let trailing = skipSpace rest
  if T.null trailing
    then Right value
    else Left ("unexpected content after the JSON value: " <> excerpt trailing)
  where
    dropByteOrderMark t = fromMaybe t (T.stripPrefix (T.singleton byteOrderMark) t)

-- | U+FEFF, which nlohmann skips at the start of a document.
byteOrderMark :: Char
byteOrderMark = '\xFEFF'

-- | JSON's insignificant whitespace: space, tab, newline, carriage return.
skipSpace :: Text -> Text
skipSpace = T.dropWhile (\c -> c == ' ' || c == '\t' || c == '\n' || c == '\r')

-- | The start of the input a parse stopped at, for an error message.
excerpt :: Text -> Text
excerpt t
  | T.null t = "end of input"
  | otherwise = "'" <> T.take excerptLength t <> "'"

-- | How much of the offending input an error quotes.
excerptLength :: Int
excerptLength = 20

-- | One value, its leading whitespace already skipped.
parseValue :: IntegerRange -> Text -> Either Text (Json, Text)
parseValue range t = case T.uncons t of
  Nothing -> Left "unexpected end of input, expected a JSON value"
  Just ('{', rest) -> parseObject range (skipSpace rest)
  Just ('[', rest) -> parseArray range (skipSpace rest)
  Just ('"', rest) -> do
    (s, afterString) <- parseString rest
    pure (JsonString s, afterString)
  Just ('t', _) -> literal "true" (JsonBool True)
  Just ('f', _) -> literal "false" (JsonBool False)
  Just ('n', _) -> literal "null" JsonNull
  Just (c, _)
    | c == '-' || isDigit c -> parseNumber range t
  Just _ -> Left ("unexpected " <> excerpt t <> ", expected a JSON value")
  where
    literal word value = case T.stripPrefix word t of
      Just rest -> Right (value, rest)
      Nothing -> Left ("invalid literal " <> excerpt t)

-- | The elements of an array, after its @[@ and any whitespace.
parseArray :: IntegerRange -> Text -> Either Text (Json, Text)
parseArray range t = case T.uncons t of
  Just (']', rest) -> Right (JsonArray [], rest)
  _ -> elements [] t
  where
    elements !acc s = do
      (value, rest) <- parseValue range s
      let after = skipSpace rest
      case T.uncons after of
        Just (',', more) -> elements (value : acc) (skipSpace more)
        Just (']', more) -> Right (JsonArray (reverse (value : acc)), more)
        _ -> Left ("unexpected " <> excerpt after <> " in an array, expected ',' or ']'")

-- | The members of an object, after its @{@ and any whitespace.
parseObject :: IntegerRange -> Text -> Either Text (Json, Text)
parseObject range t = case T.uncons t of
  Just ('}', rest) -> Right (JsonObject Map.empty, rest)
  _ -> members Map.empty t
  where
    members !acc s = case T.uncons s of
      Just ('"', afterQuote) -> do
        (name, afterName) <- parseString afterQuote
        let beforeColon = skipSpace afterName
        case T.uncons beforeColon of
          Just (':', afterColon) -> do
            (value, afterValue) <- parseValue range (skipSpace afterColon)
            let updated = Map.insert name value acc
                after = skipSpace afterValue
            case T.uncons after of
              Just (',', more) -> members updated (skipSpace more)
              Just ('}', more) -> Right (JsonObject updated, more)
              _ -> Left ("unexpected " <> excerpt after <> " in an object, expected ',' or '}'")
          _ -> Left ("unexpected " <> excerpt beforeColon <> " after a member name, expected ':'")
      _ -> Left ("unexpected " <> excerpt s <> " in an object, expected a member name")

-- | The rest of a string after its opening quote.  A control character
-- must arrive escaped; nlohmann rejects one written raw.
parseString :: Text -> Either Text (Text, Text)
parseString = collect []
  where
    collect !chunks t =
      let (plain, rest) = T.break special t
          gathered = plain : chunks
       in case T.uncons rest of
            Nothing -> Left "unterminated string"
            Just ('"', more) -> Right (T.concat (reverse gathered), more)
            Just ('\\', more) -> do
              (decoded, afterEscape) <- parseEscape more
              collect (decoded : gathered) afterEscape
            Just (c, _) -> Left ("control character U+" <> hex4 (ord c) <> " must be escaped")
    special c = c == '"' || c == '\\' || c < ' '
    hex4 n = T.justifyRight 4 '0' (T.toUpper (T.pack (showHex n "")))

-- | One escape sequence, after its backslash.  A @\\u@ escape naming a
-- UTF-16 high surrogate must be followed by one naming a low surrogate,
-- and a low surrogate alone is an error, as nlohmann's lexer has it.
parseEscape :: Text -> Either Text (Text, Text)
parseEscape t = case T.uncons t of
  Just ('"', rest) -> Right ("\"", rest)
  Just ('\\', rest) -> Right ("\\", rest)
  Just ('/', rest) -> Right ("/", rest)
  Just ('b', rest) -> Right ("\b", rest)
  Just ('f', rest) -> Right ("\f", rest)
  Just ('n', rest) -> Right ("\n", rest)
  Just ('r', rest) -> Right ("\r", rest)
  Just ('t', rest) -> Right ("\t", rest)
  Just ('u', rest) -> do
    (unit, afterUnit) <- codeUnit rest
    codePoint unit afterUnit
  _ -> Left ("invalid escape \\" <> T.take 1 t)
  where
    codePoint unit afterUnit
      | isHighSurrogate unit = case T.stripPrefix "\\u" afterUnit of
          Just afterMarker -> do
            (low, afterLow) <- codeUnit afterMarker
            if isLowSurrogate low
              then Right (T.singleton (chr (surrogateBase + (unit - highSurrogateMin) * surrogateSpan + (low - lowSurrogateMin))), afterLow)
              else Left "a high surrogate must be followed by a low surrogate"
          Nothing -> Left "a high surrogate must be followed by a low surrogate"
      | isLowSurrogate unit = Left "a low surrogate must follow a high surrogate"
      | otherwise = Right (T.singleton (chr unit), afterUnit)
    isHighSurrogate u = u >= highSurrogateMin && u < lowSurrogateMin
    isLowSurrogate u = u >= lowSurrogateMin && u < lowSurrogateMin + surrogateSpan

-- | UTF-16 surrogate ranges: high surrogates from U+D800, low from U+DC00,
-- each 0x400 wide, a pair naming a code point from U+10000.
highSurrogateMin, lowSurrogateMin, surrogateSpan, surrogateBase :: Int
highSurrogateMin = 0xD800
lowSurrogateMin = 0xDC00
surrogateSpan = 0x400
surrogateBase = 0x10000

-- | The four hex digits of a @\\u@ escape, either case.
codeUnit :: Text -> Either Text (Int, Text)
codeUnit t =
  let (digits, rest) = T.splitAt 4 t
   in case traverse hexValue (T.unpack digits) of
        Just values
          | length values == 4 -> Right (foldl' (\acc v -> acc * 16 + v) 0 values, rest)
        _ -> Left ("invalid \\u escape " <> excerpt t)
  where
    hexValue c
      | isDigit c = Just (ord c - ord '0')
      | c >= 'a' && c <= 'f' = Just (ord c - ord 'a' + 10)
      | c >= 'A' && c <= 'F' = Just (ord c - ord 'A' + 10)
      | otherwise = Nothing

-- | A number, as nlohmann's lexer scans it: an optional minus, an integer
-- part with no leading zero, an optional fraction and exponent.  One with
-- neither is an integer while it fits nlohmann's integer types (strtoll
-- below zero, strtoull above) and a float past them, as nlohmann falls
-- back; any other is a float (strtod, which 'readMaybe' matches: correctly
-- rounded, infinite past the double range).  An infinite one is an error,
-- nlohmann's out_of_range 406.
parseNumber :: IntegerRange -> Text -> Either Text (Json, Text)
parseNumber range t = do
  let (sign, afterSign) = case T.stripPrefix "-" t of
        Just rest -> ("-", rest)
        Nothing -> ("", t)
  (integral, afterIntegral) <- integerPart afterSign
  (fraction, afterFraction) <- optionalPart "." afterIntegral
  (exponentText, afterExponent) <- exponentPart afterFraction
  let lexeme = sign <> integral <> fraction <> exponentText
      asFloat = case readMaybe (T.unpack lexeme) of
        Just d
          | isInfinite d -> Left ("number overflow parsing '" <> lexeme <> "'")
          | otherwise -> Right (JsonFloat d, afterExponent)
        Nothing -> Left ("invalid number " <> excerpt t)
  if T.null fraction && T.null exponentText
    then case readMaybe (T.unpack lexeme) of
      Just n
        | n >= toInteger (minBound :: Int64) && n <= toInteger (maxBound :: Int64) -> Right (JsonInt n, afterExponent)
        | n > toInteger (maxBound :: Int64) && n <= toInteger (maxBound :: Word64) -> case range of
            NlohmannRange -> Right (JsonInt n, afterExponent)
            NixRange -> Left ("unsigned json number " <> T.pack (show n) <> " outside of Nix integer range")
      _ -> asFloat
    else asFloat
  where
    integerPart s = case T.uncons s of
      Just ('0', rest) -> Right ("0", rest)
      Just (c, _)
        | isDigit c -> Right (T.span isDigit s)
      _ -> Left ("invalid number " <> excerpt t)
    optionalPart marker s = case T.stripPrefix marker s of
      Nothing -> Right ("", s)
      Just rest -> digitsAfter marker rest
    exponentPart s = case T.uncons s of
      Just (e, rest)
        | e == 'e' || e == 'E' -> case T.uncons rest of
            Just (signChar, afterSignChar)
              | signChar == '+' || signChar == '-' -> digitsAfter (T.pack [e, signChar]) afterSignChar
            _ -> digitsAfter (T.singleton e) rest
      _ -> Right ("", s)
    digitsAfter marker s = case T.span isDigit s of
      (digits, rest)
        | T.null digits -> Left ("invalid number " <> excerpt t)
        | otherwise -> Right (marker <> digits, rest)