packages feed

hq-json-0.1.1: src/HQ/JSON/Decoder/Skip.hs

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE StrictData #-}
{-# LANGUAGE NoImplicitPrelude #-}

-- | Text-level value skipping: validate like the decoder but materialize nothing.
module HQ.JSON.Decoder.Skip
  ( skipContainerText,
    skipMemberValueText,
    skipStringCollect,
    skipNumberCollect,
    collectExpect,
    skipExpect,
    finishContainer,
    SkipExpect (..),
    SkipRest (..),
    Collected (..),
    CollectPaused (..),
    RawBody (..),
    RawSink (..),
    collectBudget,
  )
where

import Control.Exception (throwIO)
import Data.Char (isDigit)
import Data.Text qualified as T
import GHC.Show (Show (showsPrec), showString)
import HQ.Early (Early, leave)
import HQ.Error (HQError (..))
import HQ.JSON.Decoder.Core
import HQ.JSON.Decoder.Error (DecodeError (..))
import HQ.JSON.Decoder.Number
import HQ.JSON.Event (JSONEvent (..))
import Relude hiding (Compose, id, many, some, state)
import Streaming.Prelude qualified as S

-- | Raw collected bytes: fragments newest-first with their total character count.
data RawBody = RawBody {rawFrags :: [Text], rawSize :: Int}

-- | Where decoding stands: pending input text and the live text stream after it.
data SkipRest = SkipRest
  { skipRestInput :: Text,
    skipRestStream :: StreamIO Text ()
  }

-- | Result of collecting one value: where decoding stands and the collected body.
data Collected = Collected
  { collectedRest :: SkipRest,
    collectedBody :: RawBody
  }

instance Semigroup RawBody where
  RawBody f1 s1 <> RawBody f2 s2 = RawBody (f1 <> f2) (s1 + s2)

instance Monoid RawBody where mempty = RawBody [] 0

-- | One collected character.
rawChar :: Char -> RawBody
rawChar c = RawBody [T.singleton c] 1

-- | Output of the structural/string scanners: either discarded ('()')
-- or accumulated ('RawBody'). The two instances specialize away, so the
-- discard path compiles to the old hand-written skip code (no fragment
-- allocation) while both modes share one scanner. See SPECIALIZE pragmas
-- below.
class RawSink s where
  sinkChar :: Char -> s -> s
  sinkText :: Text -> s -> s
  sinkAppend :: s -> s -> s
  sinkEmbed :: RawBody -> s

  -- | Budget check at the top of 'generalExpect': a no-op for the
  -- discard path, a bounded-segment pause for the collect path.
  sinkCheck ::
    s ->
    SkipExpect ->
    NestDepth ->
    NestDepth ->
    [DecodeContext] ->
    Text ->
    StreamIO Text () ->
    IO ()

instance RawSink () where
  sinkChar _ _ = ()
  sinkText _ _ = ()
  sinkAppend _ _ = ()
  sinkEmbed _ = ()
  sinkCheck _ _ _ _ _ _ _ = pure ()

instance RawSink RawBody where
  sinkChar c body = rawChar c <> body
  sinkText t body
    | T.null t = body
    | otherwise = RawBody (t : rawFrags body) (rawSize body + T.length t)
  sinkAppend = (<>)
  sinkEmbed = identity
  sinkCheck body expect base depth stack input text
    | rawSize body > collectBudget =
        throwIO (CollectPaused body expect base depth stack input text)
    | otherwise = pure ()

data SkipExpect
  = ExpectValue
  | ExpectKey
  | ExpectColon
  | ExpectObjComma
  | ExpectArrComma

-- | Bodies collected past this size pause for emission: fragment
-- transients scale ~150x the body (measured), so collection proceeds
-- in bounded segments that emit verbatim and resume. Small values
-- (the common case) complete in one segment.
collectBudget :: Int
collectBudget = 16384

-- | A raw container collect paused for exceeding 'collectBudget': the
-- body so far and the exact state to resume from (position, depth
-- context and live input). The caller emits the body verbatim, then
-- resumes; the concatenation is byte-identical to one uninterrupted
-- collect.
data CollectPaused = CollectPaused
  { pausedBody :: RawBody,
    pausedExpect :: SkipExpect,
    pausedBase :: NestDepth,
    pausedDepth :: NestDepth,
    pausedStack :: [DecodeContext],
    pausedInput :: Text,
    pausedStream :: StreamIO Text ()
  }

instance Show CollectPaused where
  showsPrec _ _ =
    showString "CollectPaused: raw container segment ready, resuming collect"

instance Exception CollectPaused

-- | Pull the next chunk, skipping empties. Exhaustion here is
-- 'UnexpectedEnd'.
pullSkipText :: Early HQError -> Text -> StreamIO Text () -> IO SkipRest
pullSkipText early !input !text
  | T.null input =
      S.next text >>= \case
        Left () -> leave early (HQDecodeError UnexpectedEnd)
        Right (chunk, rest) -> pullSkipText early chunk rest
  | otherwise = pure (SkipRest input text)

-- | The next non-whitespace character, pulling more chunks as needed.
nextSkipChar :: Early HQError -> Text -> StreamIO Text () -> IO (Char, SkipRest)
nextSkipChar early input text = case T.uncons (T.dropWhile isWhitespace input) of
  Just (c, rest) -> pure (c, SkipRest rest text)
  Nothing ->
    S.next text >>= \case
      Left () -> leave early (HQDecodeError UnexpectedEnd)
      Right (chunk, rest) -> nextSkipChar early chunk rest

-- | Skip an object member value; a colon is required first.
skipMemberValueText ::
  Early HQError ->
  NestDepth ->
  [DecodeContext] ->
  Text ->
  StreamIO Text () ->
  IO SkipRest
skipMemberValueText early !depth !stack !input !text = do
  (c, SkipRest rest text') <- nextSkipChar early input text
  case c of
    ':' -> skipExpect early depth depth stack ExpectValue rest text'
    _ -> leave early (HQDecodeError ExpectedColon)

-- | Skip the rest of the container whose opening event was just emitted.
skipContainerText ::
  Early HQError ->
  JSONEvent ->
  DecoderState ->
  StreamIO Text () ->
  IO (DecoderState, StreamIO Text ())
skipContainerText early open decoder@DecoderState {..} text = case open of
  JSONBeginArray -> skipRest ExpectValue
  JSONBeginObject -> skipRest ExpectKey
  _ -> pure (decoder, text)
  where
    -- The opener is already consumed, so the skip ends when the depth
    -- pops back below its entry level.
    skipRest expect = do
      let depth = decoderNestDepth
          base = shallower depth
      (SkipRest remainder rest) <-
        skipExpect early base depth decoderStack expect decoderInput text
      case decoderStack of
        _ : ctxs ->
          pure (finishValue decoder {decoderInput = remainder, decoderStack = ctxs}, rest)
        [] -> pure (finishValue decoder {decoderInput = remainder}, rest)

-- | Pop one container context and mark the parent, after the
-- container's closing bracket was consumed. Shared by every raw
-- container collect, however many segments it took.
finishContainer :: DecoderState -> Text -> DecoderState
finishContainer decoder remainder = case decoderStack decoder of
  _ : ctxs -> finishValue decoder {decoderInput = remainder, decoderStack = ctxs}
  [] -> finishValue decoder {decoderInput = remainder}

-- | Structural loop shared by skipping (discard, '()') and collecting
-- ('RawBody'): same states, same errors, same termination; only the
-- accumulator differs. Thin wrappers below specialize it, so both call
-- sites keep their old signatures and performance.
{-# INLINE generalExpect #-}
generalExpect ::
  (RawSink s) =>
  Early HQError ->
  s ->
  NestDepth ->
  NestDepth ->
  [DecodeContext] ->
  SkipExpect ->
  Text ->
  StreamIO Text () ->
  IO (SkipRest, s)
generalExpect early body base depth stack expect input text = do
  sinkCheck body expect base depth stack input text
  case expect of
    ExpectValue -> do
      (c, SkipRest rest text') <- nextSkipChar early input text
      case c of
        '{' ->
          generalExpect early (sinkChar '{' body) base (deeper depth) (DecodeObject : stack) ExpectKey rest text'
        '[' ->
          generalExpect early (sinkChar '[' body) base (deeper depth) (DecodeArray : stack) ExpectValue rest text'
        '"' -> do
          (SkipRest rest' text'', body') <-
            generalStringGo early (sinkChar '"' body) rest text'
          generalAfterValue early body' base depth stack rest' text''
        't' -> scalarKeyword "true" rest text'
        'f' -> scalarKeyword "false" rest text'
        'n' -> scalarKeyword "null" rest text'
        _
          | c == '-' || isDigit c -> do
              let phase = fromMaybe NumberSign (numberPhaseFromFirstChar c)
              (Collected (SkipRest rest' text'') nbody) <-
                collectNumber early (rawChar c) phase rest text'
              generalAfterValue early (sinkAppend (sinkEmbed nbody) body) base depth stack rest' text''
          | c == ']' -> case stack of
              DecodeArray : ctxs ->
                generalAfterValue early (sinkChar ']' body) base (shallower depth) ctxs rest text'
              _ -> leave early (HQDecodeError (UnexpectedChar c))
          | otherwise -> leave early (HQDecodeError (UnexpectedChar c))
      where
        scalarKeyword lit r t = do
          (Collected (SkipRest rest' text'') kbody) <-
            scanKeyword early 1 r t lit
          generalAfterValue early (sinkAppend (sinkEmbed kbody) body) base depth stack rest' text''
    ExpectKey -> do
      (c, SkipRest rest text') <- nextSkipChar early input text
      case c of
        '}' -> case stack of
          _ : ctxs ->
            generalAfterValue early (sinkChar '}' body) base (shallower depth) ctxs rest text'
          [] -> leave early (HQDecodeError ExpectedObjectKey)
        '"' -> do
          (SkipRest rest' text'', body') <- generalStringGo early (sinkChar '"' body) rest text'
          generalExpect early body' base depth stack ExpectColon rest' text''
        _ -> leave early (HQDecodeError ExpectedObjectKey)
    ExpectColon -> do
      (c, SkipRest rest text') <- nextSkipChar early input text
      case c of
        ':' -> generalExpect early (sinkChar ':' body) base depth stack ExpectValue rest text'
        _ -> leave early (HQDecodeError ExpectedColon)
    ExpectObjComma -> do
      (c, SkipRest rest text') <- nextSkipChar early input text
      case c of
        ',' -> generalExpect early (sinkChar ',' body) base depth stack ExpectKey rest text'
        '}' -> case stack of
          _ : ctxs ->
            generalAfterValue early (sinkChar '}' body) base (shallower depth) ctxs rest text'
          [] -> leave early (HQDecodeError ExpectedCommaOrEnd)
        _ -> leave early (HQDecodeError ExpectedCommaOrEnd)
    ExpectArrComma -> do
      (c, SkipRest rest text') <- nextSkipChar early input text
      case c of
        ',' -> generalExpect early (sinkChar ',' body) base depth stack ExpectValue rest text'
        ']' -> case stack of
          _ : ctxs ->
            generalAfterValue early (sinkChar ']' body) base (shallower depth) ctxs rest text'
          [] -> leave early (HQDecodeError ExpectedCommaOrEnd)
        _ -> leave early (HQDecodeError ExpectedCommaOrEnd)

{-# INLINE generalAfterValue #-}
generalAfterValue ::
  (RawSink s) =>
  Early HQError ->
  s ->
  NestDepth ->
  NestDepth ->
  [DecodeContext] ->
  Text ->
  StreamIO Text () ->
  IO (SkipRest, s)
generalAfterValue early body base depth stack rest text'
  | depth <= base = pure (SkipRest rest text', body)
  | otherwise = case stack of
      DecodeArray : _ ->
        generalExpect early body base depth stack ExpectArrComma rest text'
      DecodeObject : _ ->
        generalExpect early body base depth stack ExpectObjComma rest text'
      [] -> pure (SkipRest rest text', body)

-- | Structural collect loop; ends when the depth pops back to its
-- entry level. Specialization of 'generalExpect'.
collectExpect ::
  Early HQError ->
  RawBody ->
  NestDepth ->
  NestDepth ->
  [DecodeContext] ->
  SkipExpect ->
  Text ->
  StreamIO Text () ->
  IO Collected
collectExpect early body base depth stack expect input text = do
  (rest, body') <- generalExpect early body base depth stack expect input text
  pure (Collected rest body')
{-# INLINE collectExpect #-}

-- | Structural skip loop; ends when the depth pops back to its entry level.
-- Specialization of 'generalExpect'.
skipExpect ::
  Early HQError ->
  NestDepth ->
  NestDepth ->
  [DecodeContext] ->
  SkipExpect ->
  Text ->
  StreamIO Text () ->
  IO SkipRest
skipExpect early base depth stack expect input text = do
  (rest, _) <- generalExpect early () base depth stack expect input text
  pure rest
{-# INLINE skipExpect #-}

{-# SPECIALIZE generalExpect :: Early HQError -> () -> NestDepth -> NestDepth -> [DecodeContext] -> SkipExpect -> Text -> StreamIO Text () -> IO (SkipRest, ()) #-}
{-# SPECIALIZE generalExpect :: Early HQError -> RawBody -> NestDepth -> NestDepth -> [DecodeContext] -> SkipExpect -> Text -> StreamIO Text () -> IO (SkipRest, RawBody) #-}

{-# SPECIALIZE generalAfterValue :: Early HQError -> () -> NestDepth -> NestDepth -> [DecodeContext] -> Text -> StreamIO Text () -> IO (SkipRest, ()) #-}
{-# SPECIALIZE generalAfterValue :: Early HQError -> RawBody -> NestDepth -> NestDepth -> [DecodeContext] -> Text -> StreamIO Text () -> IO (SkipRest, RawBody) #-}

-- Single string scanner shared by both modes: validates escapes exactly
-- like the decoder; '()' discards bytes, 'RawBody' captures them
-- (quotes included). Specialized below so the skip path allocates nothing.
{-# INLINE generalStringGo #-}
generalStringGo ::
  (RawSink s) => Early HQError -> s -> Text -> StreamIO Text () -> IO (SkipRest, s)
generalStringGo early !body !inp !txt = do
  let (safe, rest) = T.span isStringChar inp
      body' = sinkText safe body
  case T.uncons rest of
    Nothing -> do
      (SkipRest chunk rest') <- pullSkipText early mempty txt
      generalStringGo early body' chunk rest'
    Just (c, rest')
      | c == '"' -> pure (SkipRest rest' txt, sinkChar '"' body')
      | c == '\\' -> generalEscape early body' rest' txt
      | otherwise -> leave early (HQDecodeError (UnexpectedChar c))

{-# INLINE generalEscape #-}
generalEscape ::
  (RawSink s) => Early HQError -> s -> Text -> StreamIO Text () -> IO (SkipRest, s)
generalEscape early !body !input !text = do
  (SkipRest chunk rest) <- pullSkipText early input text
  case T.uncons chunk of
    Nothing -> generalEscape early body mempty rest
    Just (c, rest')
      | c == 'u' -> generalUnicode early (sinkText (T.pack ['\\', 'u']) body) rest' rest 0 0
      | isSimpleEscape c -> generalStringGo early (sinkText (T.pack ['\\', c]) body) rest' rest
      | otherwise -> leave early (HQDecodeError (InvalidEscape c))

{-# INLINE generalUnicode #-}
generalUnicode ::
  (RawSink s) => Early HQError -> s -> Text -> StreamIO Text () -> Int -> Int -> IO (SkipRest, s)
generalUnicode early !body !input !text !value !digits = do
  (SkipRest chunk rest0) <- pullSkipText early input text
  let (value', digits', rest') = accumulateHex value digits chunk
      piece = T.take (T.length chunk - T.length rest') chunk
      body' = sinkText piece body
  if digits' >= 4
    then generalFinishUnicode early body' rest' rest0 value'
    else
      if T.null rest'
        then generalUnicode early body' mempty rest0 value' digits'
        else leave early (HQDecodeError InvalidUnicodeEscape)

{-# INLINE generalFinishUnicode #-}
generalFinishUnicode ::
  (RawSink s) => Early HQError -> s -> Text -> StreamIO Text () -> Int -> IO (SkipRest, s)
generalFinishUnicode early !body !input !text !value
  | isHighSurrogate value = generalLowSurrogate early body input text
  | isLowSurrogate value = leave early (HQDecodeError InvalidSurrogatePair)
  | otherwise = generalStringGo early body input text

{-# INLINE generalLowSurrogate #-}
generalLowSurrogate ::
  (RawSink s) => Early HQError -> s -> Text -> StreamIO Text () -> IO (SkipRest, s)
generalLowSurrogate early !body !input !text = do
  (SkipRest chunk rest) <- pullSkipText early input text
  case T.uncons chunk of
    Nothing -> generalLowSurrogate early body mempty rest
    Just ('\\', rest') -> case T.uncons rest' of
      Nothing -> generalLowBackslash early (sinkChar '\\' body) rest
      Just ('u', rest'') ->
        generalLowDigits early (sinkText (T.pack ['\\', 'u']) body) rest'' rest 0 0
      _ -> leave early (HQDecodeError InvalidSurrogatePair)
    _ -> leave early (HQDecodeError InvalidSurrogatePair)

{-# INLINE generalLowBackslash #-}
generalLowBackslash ::
  (RawSink s) => Early HQError -> s -> StreamIO Text () -> IO (SkipRest, s)
generalLowBackslash early !body !text = do
  (SkipRest chunk rest) <- pullSkipText early mempty text
  case T.uncons chunk of
    Nothing -> generalLowBackslash early body rest
    Just ('u', rest') -> generalLowDigits early (sinkChar 'u' body) rest' rest 0 0
    _ -> leave early (HQDecodeError InvalidSurrogatePair)

{-# INLINE generalLowDigits #-}
generalLowDigits ::
  (RawSink s) => Early HQError -> s -> Text -> StreamIO Text () -> Int -> Int -> IO (SkipRest, s)
generalLowDigits early !body !input !text !value !digits
  | digits == 4 =
      if isLowSurrogate value
        then generalStringGo early body input text
        else leave early $ HQDecodeError InvalidSurrogatePair
  | otherwise = do
      (SkipRest chunk rest) <- pullSkipText early input text
      case T.uncons chunk of
        Nothing -> generalLowDigits early body mempty rest value digits
        Just _ -> do
          let (value', digits', rest') = accumulateHex value digits chunk
              consumed = digits' - digits
              piece = T.take consumed chunk
              body' = sinkText piece body
          if digits' == 4
            then
              if isLowSurrogate value'
                then generalStringGo early body' rest' rest
                else leave early $ HQDecodeError InvalidSurrogatePair
            else
              if T.null rest'
                then generalLowDigits early body' mempty rest value' digits'
                else leave early $ HQDecodeError InvalidUnicodeEscape

{-# SPECIALIZE generalStringGo :: Early HQError -> () -> Text -> StreamIO Text () -> IO (SkipRest, ()) #-}
{-# SPECIALIZE generalStringGo :: Early HQError -> RawBody -> Text -> StreamIO Text () -> IO (SkipRest, RawBody) #-}

{-# SPECIALIZE generalEscape :: Early HQError -> () -> Text -> StreamIO Text () -> IO (SkipRest, ()) #-}
{-# SPECIALIZE generalEscape :: Early HQError -> RawBody -> Text -> StreamIO Text () -> IO (SkipRest, RawBody) #-}

-- | The shared keyword scanner both modes use: match the literal from
-- @index@, then require a JSON delimiter. The literal is fixed, so
-- the body is returned whole on success.
scanKeyword ::
  Early HQError -> Int -> Text -> StreamIO Text () -> Text -> IO Collected
scanKeyword early ix0 inp0 txt0 keyword = go ix0 inp0 txt0
  where
    go !ix !inp !txt
      | ix == T.length keyword = checkDelim inp txt
      | T.null inp = do
          result <- S.next txt
          case result of
            Left () -> leave early $ HQDecodeError $ InvalidKeyword keyword
            Right (chunk, rest) -> go ix chunk rest
      | otherwise = case T.uncons inp of
          Nothing -> go ix mempty txt
          Just (c, rest)
            | c == T.index keyword ix -> go (ix + 1) rest txt
            | otherwise -> leave early $ HQDecodeError $ InvalidKeyword keyword
    checkDelim !inp !txt
      | T.null inp = do
          result <- S.next txt
          case result of
            Left () ->
              let rawBody = RawBody [keyword] (T.length keyword)
               in pure $ Collected (SkipRest mempty txt) rawBody
            Right (chunk, rest) -> checkDelim chunk rest
      | otherwise = case T.uncons inp of
          Nothing -> checkDelim mempty txt
          Just (c, _)
            | isJsonDelimiter c ->
                let rawBody = RawBody [keyword] (T.length keyword)
                 in pure $ Collected (SkipRest inp txt) rawBody
            | otherwise -> leave early (HQDecodeError (InvalidKeyword keyword))

-- | Skip a number, capturing its raw bytes.
skipNumberCollect :: Early HQError -> Text -> StreamIO Text () -> IO Collected
skipNumberCollect early input text = case T.uncons input of
  Nothing -> do
    (SkipRest chunk rest) <- pullSkipText early mempty text
    skipNumberCollect early chunk rest
  Just (c, rest) ->
    let phase = fromMaybe NumberSign (numberPhaseFromFirstChar c)
     in collectNumber early (rawChar c) phase rest text

collectNumber ::
  Early HQError ->
  RawBody ->
  NumberPhase ->
  Text ->
  StreamIO Text () ->
  IO Collected
collectNumber early body phase inp txt
  | T.null inp = do
      result <- S.next txt
      case result of
        Left ()
          | isValidNumberFinal phase -> pure (Collected (SkipRest mempty txt) body)
          | otherwise ->
              leave early $ HQDecodeError $ InvalidNumber (T.concat (reverse (rawFrags body)))
        Right (chunk, rest) -> collectNumber early body phase chunk rest
  | otherwise = loop body phase 0
  where
    len = T.length inp
    loop b p pos
      | pos >= len = collectNumber early (RawBody (inp : rawFrags b) (rawSize b + len)) p mempty txt
      | acceptsDigitRun p = runDigits b p pos
      | otherwise = stepChar b p pos
    -- Digits self-loop in these phases: consume the maximal run with
    -- one span, then dispatch once on the terminating character.
    runDigits b' p' pos' =
      let pos'' = pos' + T.length (T.takeWhile isDigit (T.drop pos' inp))
       in if pos'' >= len
            then collectNumber early (RawBody (inp : rawFrags b') (rawSize b' + len)) p' mempty txt
            else stepChar b' p' pos''
    stepChar b' p' pos' =
      let c = T.index inp pos'
          piece = T.take pos' inp
          body' = RawBody (piece : rawFrags b') (rawSize b' + pos')
       in case advanceNumber p' c of
            NumberEnd
              | isValidNumberFinal p' -> pure (Collected (SkipRest (T.drop pos' inp) txt) body')
              | otherwise ->
                  leave early
                    $ HQDecodeError
                    $ InvalidNumber (T.concat (reverse (rawFrags b') <> [piece]))
            NumberError ->
              leave early
                $ HQDecodeError
                $ InvalidNumber (T.concat (reverse (rawFrags b') <> [piece]) <> T.singleton c)
            NumberStep p'' -> loop b' p'' (pos' + 1)

-- | Skip a string, capturing its raw bytes, quotes included. The
-- opening quote seeds the body (oldest position); the closing quote
-- is consed at the end.
skipStringCollect :: Early HQError -> Text -> StreamIO Text () -> IO Collected
skipStringCollect early inp txt = do
  (SkipRest rest txt', body) <- generalStringGo early (rawChar '"') inp txt
  pure (Collected (SkipRest rest txt') body)
{-# INLINE skipStringCollect #-}