dataframe-csv-1.0.2.0: src/DataFrame/IO/CSV/Internal/Sink.hs
{-# LANGUAGE BangPatterns #-}
{- | Per-column sinks for the default CSV reader. Typed columns (explicit
schema) parse straight from the input slice into the WS-C builders;
inferred and EitherRead columns record unboxed @(start, end)@ offsets
into the input buffer (plus a cold side map for quote-unescaped cells)
for the inference pass ("DataFrame.IO.CSV.Internal.Infer").
-}
module DataFrame.IO.CSV.Internal.Sink (
Env (..),
MissingMode (..),
Sink (..),
SliceCol,
Cells (..),
cellAt,
withCell,
newSliceCol,
feedSink,
nullSink,
freezeCells,
sliceBS,
) where
import qualified Data.ByteString as BS
import qualified Data.ByteString.Unsafe as BSU
import qualified Data.IntMap.Strict as IM
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Vector.Unboxed as VU
import qualified Data.Vector.Unboxed.Mutable as VUM
import Control.Monad.ST (RealWorld, stToIO)
import Data.IORef
import Data.Word (Word8)
import DataFrame.IO.CSV.Internal.Scanner
import DataFrame.Internal.ColumnBuilder
import DataFrame.Internal.Parsing.Fast (
isMissingFieldSlice,
parseDoubleFieldSlice,
parseIntFieldSlice,
)
import Foreign.Ptr (Ptr, castPtr, plusPtr)
{- | How missing tokens are detected (audit D2: canonical list goes
through the WS-B memcmp fast path; custom lists decode and compare).
-}
data MissingMode = MissNone | MissCanonical | MissCustom
-- | Read-time environment shared by every sink.
data Env = Env
{ envBS :: !BS.ByteString
-- ^ Whole input (BOM-stripped when the caller stripped it).
, envPtr :: !(Ptr Word8)
-- ^ Base pointer of 'envBS' (valid for the whole decode).
, envMode :: !MissingMode
, envTexts :: ![T.Text]
-- ^ The effective missing-indicator list ('Data.Text' level).
}
data Sink
= SinkInt !(IntBuilder RealWorld)
| SinkDouble !(DoubleBuilder RealWorld)
| SinkText !(TextBuilder RealWorld)
| SinkBS !SliceCol
{- | Growable cell store for inferred columns: per cell a @(start, end)@
offset pair into the input (already Char8-stripped) and a 0\/1 validity
byte. Quote-unescaped cells (cold) live in an 'IM.IntMap' keyed by row;
their offset pair is @(-1, row)@.
-}
data SliceCol = SliceCol
{ scOffs :: !(IORef (VUM.IOVector Int))
, scValid :: !(IORef (VUM.IOVector Word8))
, scEsc :: !(IORef (IM.IntMap BS.ByteString))
}
-- | Frozen 'SliceCol' plus the buffer it points into.
data Cells = Cells
{ cBS :: !BS.ByteString
, cOffs :: !(VU.Vector Int)
, cEsc :: !(IM.IntMap BS.ByteString)
, cValid :: !(VU.Vector Word8)
, cLen :: !Int
}
{- | Resolve cell @i@ as a slice and continue (no 'BS.ByteString' header
is allocated for the common in-buffer case).
-}
withCell :: Cells -> Int -> (BS.ByteString -> Int -> Int -> r) -> r
withCell c !i k =
let s = VU.unsafeIndex (cOffs c) (2 * i)
e = VU.unsafeIndex (cOffs c) (2 * i + 1)
in if s >= 0
then k (cBS c) s e
else let cell = cEsc c IM.! e in k cell 0 (BS.length cell)
{-# INLINE withCell #-}
-- | Cell @i@ as a (zero-copy) 'BS.ByteString'.
cellAt :: Cells -> Int -> BS.ByteString
cellAt c i = withCell c i sliceBS
{-# INLINE cellAt #-}
newSliceCol :: Int -> IO SliceCol
newSliceCol hint = do
let cap = max 16 hint
offs <- VUM.unsafeNew (2 * cap)
val <- VUM.unsafeNew cap
SliceCol <$> newIORef offs <*> newIORef val <*> newIORef IM.empty
appendSliceCol :: SliceCol -> Int -> Int -> Int -> Word8 -> IO ()
appendSliceCol (SliceCol oRef vRef _) !row !s !e !ok = do
offs <- readIORef oRef
offs' <-
if 2 * row < VUM.length offs
then pure offs
else do
o <- VUM.unsafeGrow offs (VUM.length offs)
writeIORef oRef o
pure o
VUM.unsafeWrite offs' (2 * row) s
VUM.unsafeWrite offs' (2 * row + 1) e
val <- readIORef vRef
val' <-
if row < VUM.length val
then pure val
else do
v <- VUM.unsafeGrow val (VUM.length val)
writeIORef vRef v
pure v
VUM.unsafeWrite val' row ok
{-# INLINE appendSliceCol #-}
-- | Freeze the first @n@ cells (the kept row count).
freezeCells :: BS.ByteString -> SliceCol -> Int -> IO Cells
freezeCells bs (SliceCol oRef vRef eRef) n = do
offs <- readIORef oRef
val <- readIORef vRef
esc <- readIORef eRef
Cells bs
<$> VU.freeze (VUM.slice 0 (2 * n) offs)
<*> pure esc
<*> VU.freeze (VUM.slice 0 n val)
<*> pure n
-- | O(1) sub-'BS.ByteString' of a buffer (no copy).
sliceBS :: BS.ByteString -> Int -> Int -> BS.ByteString
sliceBS bs s e = BSU.unsafeTake (e - s) (BSU.unsafeDrop s bs)
{-# INLINE sliceBS #-}
{- | Feed one field slice @[s, e)@ of the input into a sink. @unesc@
fields (quoted with embedded @\"\"@) are unescaped into a fresh strict
'BS.ByteString' first (cold path).
-}
feedSink :: Env -> Sink -> Int -> Int -> Int -> Bool -> IO ()
feedSink env sink !row !s !e !unesc = case sink of
SinkBS sc
| unesc -> do
let fresh = unescapeQuotes (envBS env) s e
stripped = withStripC8 fresh 0 (BS.length fresh) (sliceBS fresh)
modifyIORef' (scEsc sc) (IM.insert row stripped)
appendSliceCol sc row (-1) row (missingOk env stripped)
| otherwise -> withStripC8 (envBS env) s e $ \s' e' ->
let ok = case envMode env of
MissNone -> 1
MissCanonical ->
if isMissingFieldSlice (envBS env) s' e' then 0 else 1
MissCustom -> missingOk env (sliceBS (envBS env) s' e')
in appendSliceCol sc row s' e' ok
_
| unesc ->
let fresh = unescapeQuotes (envBS env) s e
in BSU.unsafeUseAsCStringLen fresh $ \(p, l) ->
feedTyped env sink fresh (castPtr p) 0 l
| otherwise -> feedTyped env sink (envBS env) (envPtr env) s e
{-# INLINE feedSink #-}
-- | Validity byte for an already-stripped cell.
missingOk :: Env -> BS.ByteString -> Word8
missingOk env cell = case envMode env of
MissNone -> 1
MissCanonical -> if isMissingFieldSlice cell 0 (BS.length cell) then 0 else 1
MissCustom -> if TE.decodeUtf8Lenient cell `elem` envTexts env then 0 else 1
feedTyped ::
Env -> Sink -> BS.ByteString -> Ptr Word8 -> Int -> Int -> IO ()
feedTyped env sink bs !ptr !s !e = case sink of
SinkInt b -> case parseIntFieldSlice bs s e of
Just v -> stToIO (appendInt b v)
Nothing -> stToIO (appendNull b)
SinkDouble b -> case parseDoubleFieldSlice bs s e of
Just v -> stToIO (appendDouble b v)
Nothing -> stToIO (appendNull b)
SinkText b -> appendTextCell env b bs ptr s e
SinkBS _ -> error "feedTyped: SinkBS handled by feedSink"
{-# INLINE feedTyped #-}
{- | Text cells reproduce @T.strip . decodeUtf8Lenient@ plus the
missing-indicator test. Fast path: ASCII-edge slices append raw bytes
(the shared-buffer freeze decodes leniently per field); anything with
non-ASCII edges or a custom missing list goes through real 'T.strip'.
-}
appendTextCell ::
Env ->
TextBuilder RealWorld ->
BS.ByteString ->
Ptr Word8 ->
Int ->
Int ->
IO ()
appendTextCell env b bs !ptr !s !e =
withStripAscii bs s e $ \s' e' ->
if s' < e'
&& (BSU.unsafeIndex bs s' >= 0x80 || BSU.unsafeIndex bs (e' - 1) >= 0x80)
then slow
else case envMode env of
MissNone -> fast s' e'
MissCanonical ->
if isMissingFieldSlice bs s' e'
then stToIO (appendNull b)
else fast s' e'
MissCustom -> slow
where
fast s' e' = stToIO (appendTextSliceFromPtr b (ptr `plusPtr` s') (e' - s'))
slow = do
let t = T.strip (TE.decodeUtf8Lenient (sliceBS bs s e))
missing = case envMode env of
MissNone -> False
_ -> t `elem` envTexts env
if missing then stToIO (appendNull b) else stToIO (appendText b t)
{-# INLINE appendTextCell #-}
-- | Append a null cell (pad-with-null for short rows, audit D6).
nullSink :: Sink -> Int -> IO ()
nullSink sink !row = case sink of
SinkInt b -> stToIO (appendNull b)
SinkDouble b -> stToIO (appendNull b)
SinkText b -> stToIO (appendNull b)
SinkBS sc -> appendSliceCol sc row 0 0 0
{-# INLINE nullSink #-}