dataframe-fastcsv-1.1.1.0: src/DataFrame/IO/CSV/Fast/Index.hs
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE CApiFFI #-}
{-# LANGUAGE ForeignFunctionInterface #-}
{- | Delimiter-index construction for the fast CSV reader. The index buffer
is sized from an exact byte count (not 8 bytes per input byte), the SIMD
scan runs over the 64-byte-aligned prefix of the unpadded mmap, and a
scalar Haskell tail finishes the final partial chunk — so the input is
never copied or grown.
-}
module DataFrame.IO.CSV.Fast.Index (
CsvParseError (..),
getDelimiterIndices,
getDelimiterIndicesPolicy,
scalarScan,
classifyRowEnds,
fieldsInRow,
isBlankRow,
collectDataRows,
checkNoRaggedRows,
byteStringView,
lf,
cr,
comma,
tab,
quote,
) where
import qualified Data.ByteString as BS
import qualified Data.ByteString.Internal as BSI
import qualified Data.Vector.Storable as VS
import qualified Data.Vector.Storable.Mutable as VSM
import Control.Exception (Exception, throwIO)
import Control.Monad (when)
import Data.Word (Word8)
import Foreign (Ptr, castPtr)
import Foreign.C.Types (CInt (..), CSize (..), CUChar (..))
import Foreign.Marshal.Alloc (alloca)
import Foreign.Storable (peek, poke)
import DataFrame.IO.CSV (UnclosedQuotePolicy (..))
{- | Exceptions raised by the fast CSV parser. Catchable with
'Control.Exception.catch' or 'Control.Exception.try'.
-}
data CsvParseError
= {- | The input ends with a quoted field that was never closed. The
PCLMUL quote-parity chain in the SIMD scanner treats an unmatched
@\"@ as if the rest of the file were inside quotes, so we refuse
to return a silently corrupted 'DataFrame.Internal.DataFrame.DataFrame'
and raise instead.
-}
CsvUnclosedQuote
| {- | A row has a different number of fields from the header. Only
raised when 'DataFrame.IO.CSV.fastCsvOnRaggedRow' is set to
'RaiseOnRagged'. Carries the 0-based row index, the expected field
count, and the actual field count.
-}
CsvRaggedRow !Int !Int !Int
deriving (Eq, Show)
instance Exception CsvParseError
lf, cr, comma, tab, quote :: Word8
lf = 0x0A
cr = 0x0D
comma = 0x2C
tab = 0x09
quote = 0x22
-- Status codes reported via the out-parameter of 'get_delimiter_indices'.
-- Must stay in sync with @GDI_*@ in @cbits/process_csv.h@.
gdiOk, gdiUnclosedQuote :: CInt
gdiOk = 0
gdiUnclosedQuote = 1
{- | Return value that the C helper uses to tell Haskell \"SIMD isn't
available on this build / CPU, run the pure-Haskell state machine.\"
Mirrors @GDI_SIMD_UNAVAILABLE@ in @cbits/process_csv.h@. 'CSize' is
unsigned so this has the same bit pattern as 'maxBound'.
-}
simdUnavailable :: CSize
simdUnavailable = maxBound
foreign import capi "process_csv.h get_delimiter_indices"
get_delimiter_indices ::
Ptr CUChar -> -- input
CSize -> -- input size
CUChar -> -- separator character
Ptr CSize -> -- result array
Ptr CInt -> -- status out-parameter
IO CSize -- occupancy of result array
-- | Zero-copy 'BS.ByteString' view of the first @n@ bytes of a vector.
byteStringView :: Int -> VS.Vector Word8 -> BS.ByteString
byteStringView n v = BSI.fromForeignPtr (fst (VS.unsafeToForeignPtr0 v)) 0 n
{-# INLINE byteStringView #-}
{- | Locate delimiter byte positions in the first @originalLen@ bytes of
@csvFile@. Treats an unclosed quoted field at EOF as a hard error; callers
that want to suppress the exception can use 'getDelimiterIndicesPolicy'
with 'BestEffort'.
-}
{-# INLINE getDelimiterIndices #-}
getDelimiterIndices ::
Word8 ->
Int ->
VS.Vector Word8 ->
IO (VS.Vector CSize)
getDelimiterIndices = getDelimiterIndicesPolicy RaiseOnUnclosedQuote
getDelimiterIndicesPolicy ::
UnclosedQuotePolicy ->
Word8 ->
Int ->
VS.Vector Word8 ->
IO (VS.Vector CSize)
getDelimiterIndicesPolicy policy separator contentLen csvFile = do
-- Exact upper bound on the index count: every output index is an
-- unquoted separator or newline byte, so two memchr-speed counts
-- (plus one synthetic EOF slot) right-size the buffer (audit F4).
let contentBS = byteStringView contentLen csvFile
capacity = BS.count separator contentBS + BS.count lf contentBS + 1
resultMV <- VSM.unsafeNew capacity
-- The C scanner reads whole 64-byte chunks; cap it at the largest
-- chunk boundary inside the content so no padding is needed and the
-- input never has to be grown or copied (audit F6). The loop in C
-- runs while i < len - 64, hence the + 64.
let simdLen = (contentLen `div` 64) * 64
(numFound, status) <- VS.unsafeWith csvFile $ \buffer ->
alloca $ \statusPtr -> do
poke statusPtr gdiOk
n <- VSM.unsafeWith resultMV $ \indicesPtr ->
get_delimiter_indices
(castPtr buffer)
(fromIntegral (simdLen + 64))
(fromIntegral separator)
(castPtr indicesPtr)
statusPtr
s <- peek statusPtr
pure (n, s)
-- The status reports the quote parity at the end of the SIMD region,
-- which is exactly the starting state for the scalar tail.
(found, inQuote) <-
if numFound == simdUnavailable
then scalarScan separator csvFile resultMV 0 0 contentLen False
else
scalarScan
separator
csvFile
resultMV
(fromIntegral numFound)
simdLen
contentLen
(status == gdiUnclosedQuote)
when (inQuote && policy == RaiseOnUnclosedQuote) (throwIO CsvUnclosedQuote)
-- Synthetic EOF delimiter when the file does not end in a newline.
finalLen <-
if contentLen > 0 && VS.unsafeIndex csvFile (contentLen - 1) /= lf
then do
VSM.unsafeWrite resultMV found (fromIntegral contentLen)
pure (found + 1)
else pure found
VS.unsafeFreeze (VSM.slice 0 finalLen resultMV)
{- | RFC 4180 quote-parity state machine over bytes @[from, to)@, appending
delimiter indices at @writeIdx@. Returns the next write index and whether
the scan ended inside a quoted region. Serves as both the SIMD tail and the
full fallback when SIMD is unavailable.
-}
scalarScan ::
Word8 ->
VS.Vector Word8 ->
VSM.IOVector CSize ->
Int ->
Int ->
Int ->
Bool ->
IO (Int, Bool)
scalarScan separator file out writeIdx from to = go writeIdx from
where
go !idx !i !inQ
| i >= to = pure (idx, inQ)
| otherwise =
let b = VS.unsafeIndex file i
in if inQ
then go idx (i + 1) (b /= quote)
else
if b == quote
then go idx (i + 1) True
else
if b == lf || b == separator
then do
VSM.unsafeWrite out idx (fromIntegral i)
go (idx + 1) (i + 1) False
else go idx (i + 1) False
{- | Walk every data row and throw 'CsvRaggedRow' on the first one whose
field count differs from the header. Only called when the user opts
into 'RaiseOnRagged' via 'DataFrame.IO.CSV.fastCsvOnRaggedRow'.
-}
checkNoRaggedRows ::
VS.Vector Int ->
VS.Vector Int ->
Int ->
IO ()
checkNoRaggedRows rowEnds dataRows numCol =
VS.mapM_
( \r ->
let actual = fieldsInRow rowEnds r
in when (actual /= numCol) $
throwIO (CsvRaggedRow r numCol actual)
)
dataRows
{- | Classify each entry in the flat delimiter vector as either a row
terminator or a field terminator, and return the indices-into-the-vector
of every row terminator. A position counts as a row break if either
* the byte at that position is @\\n@, or
* the position is beyond the original content length, which only
happens for the synthetic end-of-file delimiter written when a file
does not end in a newline.
Field terminators (commas / tabs / the configured separator) are left
implicit: anything between consecutive row terminators is a field.
-}
{-# INLINE classifyRowEnds #-}
classifyRowEnds :: VS.Vector Word8 -> Int -> VS.Vector CSize -> VS.Vector Int
classifyRowEnds file contentLen =
VS.findIndices isRowBreak
where
isRowBreak pos =
let p = fromIntegral pos :: Int
in p >= contentLen || VS.unsafeIndex file p == lf
{- | Number of fields that row @r@ contains, derived directly from the
gap between consecutive entries in @rowEnds@.
-}
{-# INLINE fieldsInRow #-}
fieldsInRow :: VS.Vector Int -> Int -> Int
fieldsInRow rowEnds r =
let endIdx = VS.unsafeIndex rowEnds r
startIdx = if r == 0 then 0 else VS.unsafeIndex rowEnds (r - 1) + 1
in endIdx - startIdx + 1
{- | Does row @r@ contain exactly one, empty field? That is the case for
a bare @\\n@ or a @\\r\\n@ \x2014 blank lines, which we skip by default to
match pandas / polars @skip_blank_lines@ semantics. We also treat a
row whose only byte is @\\r@ as blank, so CRLF-terminated blank lines
don't leak through when only the @\\n@ counts as a row break.
-}
{-# INLINE isBlankRow #-}
isBlankRow ::
VS.Vector Word8 ->
VS.Vector CSize ->
VS.Vector Int ->
Int ->
Int ->
Bool
isBlankRow file delimiters rowEnds contentLen r =
let endIdx = VS.unsafeIndex rowEnds r
startIdx = if r == 0 then 0 else VS.unsafeIndex rowEnds (r - 1) + 1
numFields = endIdx - startIdx + 1
in numFields == 1
&& let fieldEndRaw =
fromIntegral (VS.unsafeIndex delimiters startIdx) :: Int
fieldEnd = min fieldEndRaw contentLen
fieldStart =
if startIdx == 0
then 0
else
fromIntegral
(VS.unsafeIndex delimiters (startIdx - 1))
+ 1
fieldLen = fieldEnd - fieldStart
in fieldLen == 0
|| ( fieldLen == 1
&& VS.unsafeIndex file fieldStart == cr
)
{- | Select the row indices that contain actual data: skip @[0 .. skip - 1]@
and drop any blank rows from the remainder.
-}
{-# INLINE collectDataRows #-}
collectDataRows ::
VS.Vector Word8 ->
VS.Vector CSize ->
VS.Vector Int ->
Int ->
Int ->
Int ->
VS.Vector Int
collectDataRows file delimiters rowEnds contentLen skip total =
VS.filter
(not . isBlankRow file delimiters rowEnds contentLen)
(VS.generate (total - skip) (+ skip))