hq-json-0.1.1: src/HQ/Runner.hs
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE StrictData #-}
{-# LANGUAGE NoImplicitPrelude #-}
module HQ.Runner
( executeRunnerAction,
jsonRunner,
foldDocuments,
rewriteDocuments,
)
where
import Control.Exception (evaluate, try)
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as LBS
import Data.Text qualified as T
import Data.Text.Encoding (Decoding (..), streamDecodeUtf8)
import Data.Text.IO (hPutStrLn)
import HQ.Early (Early, leave, runEarly)
import HQ.Error (HQError (..), renderHQError)
import HQ.JSON.Decoder (StreamIO, initialDecoder)
import HQ.JSON.Encoder (BSStream, EmitState, EncoderConfig (..), encode, initialEmitState)
import HQ.Optic (Optic)
import HQ.Query (Query (..))
import HQ.Runner.Cursor
import HQ.Runner.Error (RunnerError (..))
import HQ.Runner.Fold
import HQ.Runner.Rewrite
import HQ.Runner.Rewrite.Emit (RewriteContinuation, RewriteResult (..))
import HQ.Runner.Take
import Relude hiding (Compose, Const)
import Streaming (Of, Stream)
import Streaming.Prelude qualified as S
import System.IO (hSetBinaryMode)
data RunnerEnv = RunnerEnv
{ runnerEnvEarly :: Early HQError,
runnerEnvQuery :: Query,
runnerEnvInput :: StreamIO ByteString (),
runnerEnvConfig :: EncoderConfig
}
newtype RunnerF a = Runner (ReaderT RunnerEnv IO a)
deriving newtype
( Functor,
Applicative,
Monad,
MonadReader RunnerEnv
)
type RunnerAction = RunnerF (BSStream IO ())
-- | Evaluate a runner action into an output byte‑stream without consuming it.
-- Useful for testing or when the caller wants to further process the
-- stream (e.g., piping to another command). It returns a lazy 'BSStream' that
-- can be consumed later. Errors are propagated via the 'Early' exceptions.
evalRunnerAction ::
Early HQError ->
RunnerAction ->
Query ->
EncoderConfig ->
Handle ->
IO (BSStream IO ())
evalRunnerAction early (Runner runner) query config handle = runReaderT runner env
where
env = RunnerEnv early query (streamHandle 1024 handle) config
-- | Run a query to stdout; errors go to stderr with a failing exit.
executeRunnerAction :: RunnerAction -> Query -> EncoderConfig -> Handle -> IO ()
executeRunnerAction runner query encConfig handle = do
hSetBuffering stdout $ BlockBuffering Nothing
hSetBinaryMode stdout True
r <- runEarly $ \early -> do
byteStream <- evalRunnerAction early runner query encConfig handle
S.mapM_ write byteStream
case r of
Left e -> hPutStrLn stderr (renderHQError e) >> exitFailure
Right v -> pure v
where
write = liftIO . LBS.hPut stdout
-- | Read 'ByteString' chunks lazily from a handle.
-- The chunk size defaults to 1024 bytes, which is a reasonable trade‑off for
-- typical inputs (around 100 MiB). The function yields a stream of ByteString
-- chunks until EOF.
streamHandle :: Int -> Handle -> StreamIO ByteString ()
streamHandle size handle = do
chunk <- liftIO $ BS.hGetSome handle size
if BS.null chunk
then pure ()
else S.yield chunk >> streamHandle size handle
-- | Run the JSON runner, producing a stream of encoded JSON documents.
jsonRunner :: RunnerAction
jsonRunner = do
RunnerEnv {..} <- ask
let cursor = Cursor [] initialDecoder (decodeUtf8Stream runnerEnvEarly runnerEnvInput)
foldWith docs = void $ encode runnerEnvConfig 65536 docs
rewriteWith rewriter optic =
void
$ rewriteDocuments
runnerEnvEarly
(runRewrite runnerEnvEarly rewriter optic runnerEnvConfig)
cursor
initialEmitState
pure $ case runnerEnvQuery of
Preview optic -> foldWith (takeFirstValue (runFold runnerEnvEarly optic cursor))
Fold optic -> foldWith (foldDocuments runnerEnvEarly optic cursor)
Over optic transformation -> rewriteWith (RewriteTransform transformation) optic
Delete optic -> rewriteWith RewriteDelete optic
-- | Iterate over each top‑level document in the input stream.
-- The function abstracts the common pattern of fetching the next document,
-- projecting a cursor, and applying an action that may produce a new state.
-- It is used by both 'foldDocuments' and 'rewriteDocuments'.
forDocuments ::
(Cursor -> IO (Maybe Cursor)) ->
(s -> Cursor) ->
(Cursor -> s -> Stream (Of e) IO s) ->
s ->
Stream (Of e) IO s
forDocuments next project act st = do
mcur <- lift (next (project st))
case mcur of
Nothing -> pure st
Just cur' -> act cur' st >>= forDocuments next project act
-- | Perform a fold over each top‑level JSON document.
-- The fold consumes the input cursor and yields the focused values as a
-- stream of events. It is used for queries like `fold` and `preview`.
foldDocuments :: Early HQError -> Optic -> Cursor -> EventStream Cursor
foldDocuments early optic =
forDocuments (nextDocument early) id (\cur _ -> runFold early optic cur)
-- | Perform a rewrite over each top‑level JSON document.
-- The rewrite uses a continuation that can emit transformed JSON.
-- It threads an 'EmitState' (initially empty) across documents, resetting at each document boundary.
rewriteDocuments ::
Early HQError ->
RewriteContinuation ->
Cursor ->
EmitState ->
BSStream IO RewriteResult
rewriteDocuments early action cur st =
forDocuments (nextDocument early) rewriteCursor go (RewriteResult st cur)
where
go cur' (RewriteResult st' _) = action cur' st'
-- | Decode a stream of strict 'ByteString' chunks to 'Text',
-- handling UTF-8 errors. Invalid or truncated input aborts with a
-- runner error ('InvalidUtf8').
decodeUtf8Stream :: Early HQError -> StreamIO ByteString () -> StreamIO Text ()
decodeUtf8Stream early stream = case streamDecodeUtf8 mempty of
Some _ _ cont -> go mempty cont stream
where
-- The continuation carries partial sequences internally: feed it
-- new bytes only (re-feeding the carry would decode them twice).
-- The returned carry is only inspected at end of input, where a
-- non-empty remainder means truncation.
go ::
ByteString ->
(ByteString -> Decoding) ->
StreamIO ByteString () ->
StreamIO Text ()
go leftover cont str = do
result <- lift $ S.next str
case result of
Left ()
| BS.null leftover -> pure ()
-- A non-empty carry is a truncated sequence: invalid, exactly
-- as the old all-or-nothing decode reported it.
| otherwise -> lift (leave early (HQRunnerError InvalidUtf8))
Right (chunk, rest) -> do
Some text leftover' cont' <- lift (decodeStep (cont chunk))
unless (T.null text) $ S.yield text
go leftover' cont' rest
-- Step the incremental decoder strictly: it throws
-- 'UnicodeException' on invalid input, mapped here to the
-- runner's 'InvalidUtf8' (the same errors 'decodeUtf8'' reported).
decodeStep :: Decoding -> IO Decoding
decodeStep decoding = do
result <- try (evaluate decoding) :: IO (Either UnicodeException Decoding)
case result of
Left _ -> leave early (HQRunnerError InvalidUtf8)
Right dec' -> pure dec'