packages feed

streamly-archive-0.3.0: src/Streamly/External/Archive.hs

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}

module Streamly.External.Archive
  ( -- ** Read
    readArchive,

    -- *** Read options
    ReadOptions,
    mapHeaderMaybe,

    -- ** Utility functions

    -- | Various utility functions that some might find useful.
    groupByLeft,
    eitherByLeft,
    chunkOn,
    chunkOnFold,

    -- ** Header
    Header,
    FileType (..),
    headerFileType,
    headerPathName,
    headerPathNameUtf8,
    headerSize,
  )
where

import Control.Exception
import Control.Monad.IO.Class
import Data.ByteString (ByteString)
import qualified Data.ByteString as B
import Data.Either
import Data.Foldable
import Data.Function
import qualified Data.Sequence as Seq
import Foreign
import Foreign.C.Types
import Streamly.Data.Fold (Fold)
import qualified Streamly.Data.Parser as P
import Streamly.Data.Stream.Prelude (Stream)
import qualified Streamly.Data.Stream.Prelude as S
import Streamly.Data.Unfold
import Streamly.External.Archive.Internal.Foreign
import qualified Streamly.Internal.Data.Fold as F
import Streamly.Internal.Data.IOFinalizer
import qualified Streamly.Internal.Data.Stream as S
import qualified Streamly.Internal.Data.Unfold as U

-- | Header information for an entry in the archive.
newtype Header = Header Entry

{-# INLINE headerFileType #-}
headerFileType :: Header -> IO (Maybe FileType)
headerFileType (Header e) = archive_entry_filetype e

{-# INLINE headerPathName #-}
headerPathName :: Header -> IO (Maybe ByteString)
headerPathName (Header e) = archive_entry_pathname e

{-# INLINE headerPathNameUtf8 #-}
headerPathNameUtf8 :: Header -> IO (Maybe ByteString)
headerPathNameUtf8 (Header e) = archive_entry_pathname_utf8 e

-- | Returns the file size of the entry, if it has been set; returns 'Nothing' otherwise.
{-# INLINE headerSize #-}
headerSize :: Header -> IO (Maybe Int)
headerSize (Header e) = archive_entry_size e

-- | Creates an unfold with which we can stream data out of the given archive.
--
-- By default (with 'id' as the read options modifier), we get for each entry in the archive a
-- 'Header' followed by zero or more @ByteString@s containing chunks of file data.
--
-- To modify the read options, one can use function composition.
{-# INLINE readArchive #-}
readArchive ::
  (MonadIO m) =>
  Unfold m (ReadOptions m Header -> ReadOptions m a, FilePath) (Either a ByteString)
readArchive =
  U.Unfold
    ( \(ropts, arch, buf, sz, offs, pos, ref, readHeader) ->
        if readHeader
          then do
            me <- liftIO $ archive_read_next_header arch
            case me of
              Nothing -> do
                liftIO $ runIOFinalizer ref
                return U.Stop
              Just e -> do
                let hdr = Header e
                m <- _mapHeaderMaybe ropts hdr
                return $ case m of
                  Nothing -> U.Skip (ropts, arch, buf, sz, offs, 0, ref, True)
                  Just a -> U.Yield (Left a) (ropts, arch, buf, sz, offs, 0, ref, False)
          else do
            (bs, done) <- liftIO $ archive_read_data_block arch buf sz offs pos
            return $
              if B.length bs > 0
                then
                  U.Yield
                    (Right bs)
                    (ropts, arch, buf, sz, offs, pos + fromIntegral (B.length bs), ref, done)
                else U.Skip (ropts, arch, buf, sz, offs, pos, ref, done)
    )
    ( \(modifier, fp) -> do
        (arch, buf, sz, offs, ref) <- liftIO . mask_ $ do
          arch <- liftIO archive_read_new
          buf :: Ptr (Ptr CChar) <- liftIO malloc
          sz :: Ptr CSize <- liftIO malloc
          offs :: Ptr Int64 <- liftIO malloc
          ref <- newIOFinalizer $ archive_read_free arch >> free buf >> free sz >> free offs
          return (arch, buf, sz, offs, ref)
        liftIO $ archive_read_support_filter_all arch
        liftIO $ archive_read_support_format_all arch
        liftIO $ archive_read_open_filename arch fp

        -- + We ended up with functions instead of records to avoid an error about an ambiguous
        --   monad type for defaultReadOptions when the user sets the headerFilter record.
        -- + (A dummy Proxy record worked too, but partially exporting records breaks Haddock.)
        let ropts = modifier _defaultReadOptions

        return (ropts, arch, buf, sz, offs, 0, ref, True)
    )

newtype ReadOptions m a = ReadOptions
  { _mapHeaderMaybe :: Header -> m (Maybe a)
  }

_defaultReadOptions :: (Monad m) => ReadOptions m Header
_defaultReadOptions =
  ReadOptions
    { _mapHeaderMaybe = return . Just
    }

-- | If this returns @Just@ for a header, that header (mapped to a different value if desired) and
-- any following @ByteString@ chunks are included in the 'readArchive' unfold. If this returns
-- @Nothing@ for a header, that header and any following @ByteString@ chunks are excluded from the
-- 'readArchive' unfold.
--
-- By default, all entries are included with unaltered headers.
mapHeaderMaybe :: (Header -> m (Maybe a)) -> ReadOptions m Header -> ReadOptions m a
mapHeaderMaybe x o = o {_mapHeaderMaybe = x}

-- | Groups a stream of @Either@s by the @Left@s. The provided @Fold@ processes a single @Left@
-- followed by any subsequent (zero or more) @Right@s.
{-# INLINE groupByLeft #-}
groupByLeft ::
  (Monad m) =>
  Fold m (Either a b) c ->
  Stream m (Either a b) ->
  Stream m c
groupByLeft itemFold str =
  str
    & S.parseMany (P.groupBy (\_ e -> isRight e) itemFold)
    & fmap
      ( \case
          Left _ ->
            -- groupBy is documented to never fail.
            error "unexpected parseMany/groupBy error"
          Right c -> c
      )

-- | Associates each @Right@ in a stream with the latest @Left@ that came before it.
--
-- >>> l = [Right 10, Left "a", Right 1, Right 2, Left "b", Left "c", Right 20]
-- >>> S.fold F.toList . eitherByLeft . S.fromList $ l
-- [("a",1),("a",2),("c",20)]
eitherByLeft :: (Monad m) => Stream m (Either a b) -> Stream m (a, b)
eitherByLeft s =
  S.scanl'
    ( \(curra, _) e ->
        case e of
          Left newa -> (Just newa, Nothing)
          Right newb -> (curra, Just newb)
    )
    (Nothing, Nothing)
    s
    & S.mapMaybe
      ( \(ma, mb) -> case (ma, mb) of
          (Just a, Just b) -> Just (a, b)
          _ -> Nothing
      )

-- | The state of the chunkOn stream.
data ChunkOnState_ is h
  = -- | The initial state; or a header is done being yielded.
    COInitOrYieldHeader_
  | -- | A bytestring not containing splitWd is being built up.
    COResidue_ !ByteString
  | -- | Chunks are being processed.
    COProcessChunks_ ![ByteString] !ByteString
  | -- | A stop has been asked for.
    COStop_
  | -- | A header yield has been asked for.
    COYieldHeader_ !h !is

-- | Chunks up the bytestrings following each @Left@ by the given word, discarding the given word.
-- (For instance, the word could be @10@ (newline), which gives us lines as the chunks.) The
-- bytestrings in the resulting stream are the desired chunks.
{-# INLINE chunkOn #-}
chunkOn ::
  (Monad m) =>
  Word8 ->
  Stream m (Either a ByteString) ->
  Stream m (Either a ByteString)
chunkOn splitWd (S.Stream istep isinit) =
  -- "i": input.
  S.Stream step' (isinit, COInitOrYieldHeader_)
  where
    -- A utility function to obtain (chunks, next residue) from the previous residue and the latest
    -- incoming bytestring.
    {-# INLINE toChunks #-}
    toChunks residue newbs =
      -- Non-empty newbs expected.
      let tentativeChunks = Seq.fromList . B.split splitWd $ residue `B.append` newbs
       in case tentativeChunks of
            Seq.Empty -> (Seq.empty, "")
            init' Seq.:|> last' ->
              -- Note: This logic works also when newbs ends with splitWd because then the last
              -- chunk is the empty bytestring.
              (init', last')

    -- Processes chunks obtained with toChunks.
    {-# INLINE processChunks #-}
    processChunks is [] residue =
      return $ S.Skip (is, COResidue_ residue)
    processChunks is (chunk : chunks) residue =
      return $ S.Yield (Right chunk) (is, COProcessChunks_ chunks residue)

    {-# INLINE step' #-}
    -- "is": state of the input stream.
    -- "gst": "global" state? (Inspired by '_compactOnByteCustom' in streamly-0.10.1.)
    step' gst (is, s) = case s of
      COInitOrYieldHeader_ -> do
        istep' <- istep gst is
        case istep' of
          S.Stop -> return S.Stop
          S.Skip is' -> return $ S.Skip (is', COInitOrYieldHeader_)
          S.Yield e is' -> case e of
            Left hdr -> return $ S.Yield (Left hdr) (is', COInitOrYieldHeader_)
            Right newbs -> do
              -- Note: In the initial case (and not just the yield header case), this is possible.
              -- Although a bytestring appearing initially without any preceding header is not what
              -- we have in mind for streamly-archive, we want this function to focus only on the
              -- bytestring splitting.
              let (chunks, residue') = toChunks "" newbs
              return $ S.Skip (is', COProcessChunks_ (toList chunks) residue')
      COResidue_ !residue -> do
        istep' <- istep gst is
        case istep' of
          S.Stop -> return $ S.Yield (Right residue) (is, COStop_)
          S.Skip is' -> return $ S.Skip (is', COResidue_ residue)
          S.Yield e is' -> case e of
            Left hdr -> return $ S.Yield (Right residue) (is', COYieldHeader_ hdr is')
            Right newbs -> do
              let (chunks, residue') = toChunks residue newbs
              return $ S.Skip (is', COProcessChunks_ (toList chunks) residue')
      COStop_ -> return S.Stop
      COYieldHeader_ !hdr !is' -> return $ S.Yield (Left hdr) (is', COInitOrYieldHeader_)
      COProcessChunks_ !chunks !residue ->
        processChunks is chunks residue

-- | The state of the outer 'chunkOnFold' fold.
data ChunkOnFoldState_
  = -- | The initialization of the fold is complete. This state occurs only once (in the beginning).
    Init_
  | -- | The processing of a header is complete.
    Header_
  | -- | The processing of chunks is complete, and a residue (possibly empty) has been made
    -- available.
    Chunks_ !ByteString

-- | Chunks up the bytestrings following each @Left@ by the given word, discarding the given word.
-- (For instance, the word could be @10@ (newline), which gives us lines as the chunks.) The
-- bytestrings in the provided fold are the desired chunks.
{-# INLINE chunkOnFold #-}
chunkOnFold ::
  (Monad m) =>
  Word8 ->
  Fold m (Either a ByteString) b ->
  Fold m (Either a ByteString) b
chunkOnFold splitWd (F.Fold chstep chinit chextr chfinal) =
  -- "ch": chunk.
  let -- A utility function to consume all the chunks available in the same iteration.
      {-# INLINE go #-}
      go chs [] = return $ F.Partial chs -- "chs": state of the chunk fold.
      go chs (chbs : chbss) = do
        chstep' <- chstep chs (Right chbs)
        case chstep' of
          F.Done a -> return $ F.Done a
          F.Partial chs' -> go chs' chbss
      -- A utility function to obtain (chunks, next residue) from the previous residue and the
      -- latest incoming bytestring.
      {-# INLINE toChunks #-}
      toChunks residue newbs =
        -- Non-empty newbs expected.
        let tentativeChunks = Seq.fromList . B.split splitWd $ residue `B.append` newbs
         in case tentativeChunks of
              Seq.Empty -> (Seq.empty, "")
              init' Seq.:|> last' ->
                -- Note: This logic works also when newbs ends with splitWd because then the last
                -- chunk is the empty bytestring.
                (init', last')

      {-# INLINE processHeader #-}
      processHeader chs hdr = do
        chstep' <- chstep chs (Left hdr)
        case chstep' of
          F.Done a -> return $ F.Done a
          F.Partial chs' -> return $ F.Partial (chs', Header_)
      {-# INLINE processBytestring #-}
      processBytestring chs residue chbs = do
        let (chunks, residue') = toChunks residue chbs
        chstep' <- go chs (toList chunks)
        case chstep' of
          F.Done a -> return $ F.Done a
          F.Partial chs' -> return $ F.Partial (chs', Chunks_ residue')
   in -- Note: If a file ends with "\n", we want to include the last empty line.
      F.Fold
        ( \(chs, s) e -> case s of
            Init_ -> case e of
              Left hdr -> do
                processHeader chs hdr
              Right newbs ->
                -- This case is possible. Although a bytestring appearing initially without any
                -- preceding header is not what we have in mind for streamly-archive, we want this
                -- fold to focus only on the bytestring splitting.
                processBytestring chs "" newbs
            Header_ -> case e of
              Left hdr -> do
                -- No bytestrings followed the previous header.
                processHeader chs hdr
              Right newbs ->
                processBytestring chs "" newbs
            Chunks_ residue -> case e of
              Left hdr -> do
                chstep' <- chstep chs (Right residue)
                case chstep' of
                  F.Done a -> return $ F.Done a
                  F.Partial chs' -> do
                    processHeader chs' hdr
              Right newbs ->
                processBytestring chs residue newbs
        )
        ( do
            chstep' <- chinit
            case chstep' of
              F.Done a -> return $ F.Done a
              F.Partial chs' -> return $ F.Partial (chs', Init_)
        )
        (\(chs, _) -> chextr chs)
        ( \(chs, s) -> case s of
            Chunks_ residue -> do
              chstep' <- chstep chs (Right residue)
              case chstep' of
                F.Done a -> return a
                F.Partial chs' -> chfinal chs'
            _ -> chfinal chs
        )