hq-json-0.1.1: src/HQ/JSON/Decoder/Number.hs
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE StrictData #-}
{-# LANGUAGE NoImplicitPrelude #-}
-- | Number parsing: state machine plus stepping and finalization.
module HQ.JSON.Decoder.Number where
import Data.Char (digitToInt, isDigit)
import Data.Scientific (Scientific, scientific)
import Data.Text qualified as T
import HQ.JSON.Decoder.Core
import HQ.JSON.Decoder.Error (DecodeError (..))
import HQ.JSON.Event (JSONEvent (..))
import Relude hiding (Compose, id, many, some, state)
-- | Is this phase valid at end-of-input?
isValidNumberFinal :: NumberPhase -> Bool
isValidNumberFinal NumberZero = True
isValidNumberFinal NumberNonZero = True
isValidNumberFinal NumberFraction = True
isValidNumberFinal NumberExpDigit = True
isValidNumberFinal _ = False
-- | Advance the number state machine for one character. Digits are
-- tested before delimiters: digits are the common case and never
-- delimit, so leading with 'isDigit' skips two list scans per digit.
-- (Order is behaviour-preserving: no character is both.)
advanceNumber :: NumberPhase -> Char -> NumberStep
advanceNumber phase c
| isDigit c = case phase of
NumberSign
| c == '0' -> NumberStep NumberZero
| otherwise -> NumberStep NumberNonZero
NumberZero -> NumberError
NumberNonZero -> NumberStep NumberNonZero
NumberDot -> NumberStep NumberFraction
NumberFraction -> NumberStep NumberFraction
NumberExpSign -> NumberStep NumberExpDigit
NumberExpAfterSign -> NumberStep NumberExpDigit
NumberExpDigit -> NumberStep NumberExpDigit
| isJsonDelimiter c = NumberEnd
| otherwise = case phase of
NumberSign -> NumberError
NumberZero
| c == '.' -> NumberStep NumberDot
| c == 'e' || c == 'E' -> NumberStep NumberExpSign
| otherwise -> NumberError
NumberNonZero
| c == '.' -> NumberStep NumberDot
| c == 'e' || c == 'E' -> NumberStep NumberExpSign
| otherwise -> NumberError
NumberDot -> NumberError
NumberFraction
| c == 'e' || c == 'E' -> NumberStep NumberExpSign
| otherwise -> NumberError
NumberExpSign
| c == '+' || c == '-' -> NumberStep NumberExpAfterSign
| otherwise -> NumberError
NumberExpAfterSign -> NumberError
NumberExpDigit -> NumberError
-- | Phases where digits self-loop: a maximal digit run can be consumed
-- without per-character dispatch, leaving the phase unchanged.
acceptsDigitRun :: NumberPhase -> Bool
acceptsDigitRun NumberNonZero = True
acceptsDigitRun NumberFraction = True
acceptsDigitRun NumberExpDigit = True
acceptsDigitRun _ = False
-- | Determine the starting number phase for the first character of a number.
numberPhaseFromFirstChar :: Char -> Maybe NumberPhase
numberPhaseFromFirstChar '-' = Just NumberSign
numberPhaseFromFirstChar '0' = Just NumberZero
numberPhaseFromFirstChar c
| isDigit c = Just NumberNonZero
| otherwise = Nothing
-- | Single source for "can this char start a JSON number?".
-- Canonical home next to 'numberPhaseFromFirstChar' (same char set);
-- 'Decoder.startValue' and 'Take.peekRawScalar' share it.
{-# INLINE isNumberStart #-}
isNumberStart :: Char -> Bool
isNumberStart c = isJust (numberPhaseFromFirstChar c)
startNumberState :: Char -> NumberState
startNumberState c =
NumberState
{ numberNegative = c == '-',
numberCoeff = if isDigit c then fromIntegral (digitToInt c) else 0,
numberExp = 0,
numberFracDigits = 0,
numberExpNeg = False,
numberPhase = fromMaybe NumberSign (numberPhaseFromFirstChar c),
-- First char belongs to the error payload; freed on completion.
numberFrags = [T.singleton c]
}
-- | Text consumed for the current number: prior chunks (newest
-- first) plus a slice of the current input. Forced on the error path
-- only.
numberText :: NumberState -> Text -> Int -> Text
numberText ns cur pos = T.concat (reverse (numberFrags ns)) <> T.take pos cur
-- | Error payload: exactly the consumed text.
numberErrorText :: NumberState -> Text
numberErrorText ns = T.concat (reverse (numberFrags ns))
-- | Render the accumulated components as a 'Scientific', folding
-- fraction digits into the coefficient and offsetting the exponent.
finishNumber :: NumberState -> Scientific
finishNumber ns =
(if numberNegative ns then negate else identity) (numberCoeff ns)
`scientific` fromInteger (expVal - fromIntegral (numberFracDigits ns))
where
expVal = (if numberExpNeg ns then negate else identity) (numberExp ns)
-- | Continue parsing a number: tight index loop with the numeric
-- components accumulated inline, so completion needs no intermediate
-- buffer and no re-scan. State is written back once at end, failure,
-- or input exhaustion.
stepNumber :: DecoderState -> NumberState -> Either DecodeError DecoderResult
stepNumber decoder ns0@(NumberState {..}) =
scan 0 numberCoeff numberExp numberFracDigits numberExpNeg numberPhase
where
input = decoderInput decoder
len = T.length input
scan ::
Int ->
Integer ->
Integer ->
Int ->
Bool ->
NumberPhase ->
Either DecodeError DecoderResult
scan !pos !coeff !expP !nFrac !expNeg !phase
| pos >= len =
Right
$ NeedInput
decoder
{ decoderInput = mempty,
decoderPhase =
DecoderPhaseNumber
NumberState
{ numberNegative = numberNegative,
numberCoeff = coeff,
numberExp = expP,
numberFracDigits = nFrac,
numberExpNeg = expNeg,
numberPhase = phase,
numberFrags = if T.null input then numberFrags else input : numberFrags
}
}
| otherwise =
let c = T.index input pos
in case advanceNumber phase c of
NumberEnd ->
finalizeNumber
decoder
{ decoderInput = T.drop pos input,
decoderPhase =
DecoderPhaseNumber
(pushSlice ns0 input pos)
{ numberCoeff = coeff,
numberExp = expP,
numberFracDigits = nFrac,
numberExpNeg = expNeg,
numberPhase = phase
}
}
NumberError ->
Left (InvalidNumber (numberText ns0 input pos <> T.singleton c))
NumberStep phase' ->
scan (pos + 1) coeff' expP' nFrac' expNeg' phase'
where
(coeff', expP', nFrac', expNeg')
| isDigit c =
let d = fromIntegral (digitToInt c) :: Integer
in if isExpPhase phase
then (coeff, expP * 10 + d, nFrac, expNeg)
else
if phase == NumberDot || phase == NumberFraction
then (coeff * 10 + d, expP, nFrac + 1, expNeg)
else (coeff * 10 + d, expP, nFrac, expNeg)
| c == '-' = (coeff, expP, nFrac, True)
| otherwise = (coeff, expP, nFrac, expNeg)
-- | Record the consumed slice of the current chunk, so a later
-- rejection ('finalizeNumber', 'finish') reports the full consumed
-- text. One slice header per number; freed on completion.
pushSlice :: NumberState -> Text -> Int -> NumberState
pushSlice ns cur pos
| pos <= 0 = ns
| otherwise = ns {numberFrags = T.take pos cur : numberFrags ns}
-- | Digit runs accumulate into the exponent once one started.
isExpPhase :: NumberPhase -> Bool
isExpPhase NumberExpSign = True
isExpPhase NumberExpAfterSign = True
isExpPhase NumberExpDigit = True
isExpPhase _ = False
finalizeNumber :: DecoderState -> Either DecodeError DecoderResult
finalizeNumber decoder = case decoderPhase decoder of
DecoderPhaseNumber numState
| isValidNumberFinal (numberPhase numState) ->
emitEvent
(JSONNumber (finishNumber numState))
(decoderInput decoder)
decoder
| otherwise -> Left (InvalidNumber (numberErrorText numState))
_ -> Left (InvalidNumber mempty)