packages feed

dataframe-csv-1.0.2.0: src/DataFrame/IO/CSV/Internal/Scanner.hs

{-# LANGUAGE BangPatterns #-}

{- | RFC 4180 field scanner for the default CSV reader (Round-2 WS-D).
One pass over a strict 'BS.ByteString'; cassava-parity semantics pinned
by the @IO.CsvGolden@ suite: quotes open fields only at the first byte,
a double quote inside an unquoted field is an error, garbage after a
closing quote is an error, an unclosed quote consumes to EOF, and rows
end at @\\n@, @\\r\\n@ or a lone @\\r@.
-}
module DataFrame.IO.CSV.Internal.Scanner (
    Term,
    termSep,
    termEol,
    termEof,
    withField,
    withStripC8,
    withStripAscii,
    unescapeQuotes,
    isBlankRecordAt,
) where

import qualified Data.ByteString as BS
import qualified Data.ByteString.Internal as BSI
import qualified Data.ByteString.Unsafe as BSU

import Data.Word (Word8)
import Foreign.Ptr (Ptr)
import Foreign.Storable (pokeByteOff)

-- | What ended a field: another field follows, the row ended, or EOF.
type Term = Int

termSep, termEol, termEof :: Term
termSep = 0
termEol = 1
termEof = 2

quote, nl, cr :: Word8
quote = 34
nl = 10
cr = 13

parseError :: Int -> String -> a
parseError off what =
    error ("CSV Parse Error: " ++ what ++ " at byte " ++ show off)

{- | Scan one field starting at @pos@ and continue with
@k contentStart contentEnd needsUnescape term nextPos@. Quoted fields
yield the bytes between the outer quotes; @needsUnescape@ is set when
the content contains doubled quotes ('unescapeQuotes' collapses them).
-}
{-# INLINE withField #-}
withField ::
    BS.ByteString ->
    Int ->
    Word8 ->
    Int ->
    (Int -> Int -> Bool -> Term -> Int -> r) ->
    r
withField bs !len !sep !pos k
    | pos < len && BSU.unsafeIndex bs pos == quote = quoted (pos + 1) False
    | otherwise = unquoted pos
  where
    -- Field terminator dispatch shared by both shapes.
    {-# INLINE finish #-}
    finish !cs !ce !unesc !i
        | i >= len = k cs ce unesc termEof i
        | otherwise = case BSU.unsafeIndex bs i of
            b
                | b == sep -> k cs ce unesc termSep (i + 1)
                | b == nl -> k cs ce unesc termEol (i + 1)
                | b == cr ->
                    if i + 1 < len && BSU.unsafeIndex bs (i + 1) == nl
                        then k cs ce unesc termEol (i + 2)
                        else k cs ce unesc termEol (i + 1)
                | otherwise ->
                    parseError i "malformed quoted field (garbage after closing quote)"
    unquoted !i
        | i >= len = k pos i False termEof i
        | otherwise = case BSU.unsafeIndex bs i of
            b
                | b == sep -> k pos i False termSep (i + 1)
                | b == nl -> k pos i False termEol (i + 1)
                | b == cr ->
                    if i + 1 < len && BSU.unsafeIndex bs (i + 1) == nl
                        then k pos i False termEol (i + 2)
                        else k pos i False termEol (i + 1)
                | b == quote -> parseError i "stray double quote in unquoted field"
                | otherwise -> unquoted (i + 1)
    quoted !i !unesc
        | i >= len = k (pos + 1) len unesc termEof len -- unclosed: rest of input
        | BSU.unsafeIndex bs i == quote =
            if i + 1 < len && BSU.unsafeIndex bs (i + 1) == quote
                then quoted (i + 2) True
                else finish (pos + 1) i unesc (i + 1)
        | otherwise = quoted (i + 1) unesc

{- | Whether the record starting at @pos@ is a blank record (a single
empty field, quoted or not, then end of row) — cassava drops these.
Assumes @pos < len@. Errors propagate from 'withField'.
-}
{-# INLINE isBlankRecordAt #-}
isBlankRecordAt :: BS.ByteString -> Int -> Word8 -> Int -> Bool
isBlankRecordAt bs !len !sep !pos =
    withField bs len sep pos (\cs ce _ term _ -> cs == ce && term /= termSep)

{- | Collapse doubled quotes (@\"\"@ → @\"@) in @[s, e)@ into a fresh strict
'BS.ByteString'. Every quote in the range is the first of a pair.
-}
unescapeQuotes :: BS.ByteString -> Int -> Int -> BS.ByteString
unescapeQuotes bs !s !e = BSI.unsafeCreateUptoN (e - s) (go s 0)
  where
    go !i !j !dst
        | i >= e = pure j
        | otherwise = do
            let w = BSU.unsafeIndex bs i
            pokeByteOff (dst :: Ptr Word8) j w
            if w == quote then go (i + 2) (j + 1) dst else go (i + 1) (j + 1) dst

{- | Strip with @Data.ByteString.Char8.strip@ semantics (Latin-1
whitespace: HT..CR, space and NBSP 0xA0) and continue with the
stripped range.
-}
{-# INLINE withStripC8 #-}
withStripC8 :: BS.ByteString -> Int -> Int -> (Int -> Int -> r) -> r
withStripC8 bs !s0 !e0 k = k s e
  where
    isC8Space w = w == 32 || (w - 9) <= 4 || w == 160
    s = skipF s0
    e = skipB e0
    skipF !i
        | i < e0 && isC8Space (BSU.unsafeIndex bs i) = skipF (i + 1)
        | otherwise = i
    skipB !i
        | i > s && isC8Space (BSU.unsafeIndex bs (i - 1)) = skipB (i - 1)
        | otherwise = i

{- | Strip ASCII whitespace (the ASCII subset of @Data.Text.strip@) and
continue with the stripped range. Callers must fall back to a full
'Data.Text.strip' when the stripped range still has non-ASCII edges.
-}
{-# INLINE withStripAscii #-}
withStripAscii :: BS.ByteString -> Int -> Int -> (Int -> Int -> r) -> r
withStripAscii bs !s0 !e0 k = k s e
  where
    isAsciiSpace w = w == 32 || (w - 9) <= 4
    s = skipF s0
    e = skipB e0
    skipF !i
        | i < e0 && isAsciiSpace (BSU.unsafeIndex bs i) = skipF (i + 1)
        | otherwise = i
    skipB !i
        | i > s && isAsciiSpace (BSU.unsafeIndex bs (i - 1)) = skipB (i - 1)
        | otherwise = i