Skip to content

Commit 7e116c6

Browse files
authored
refactor: enhance field parsing by adding quote detection and optimization flags (#72)
* refactor: enhance field parsing by adding quote detection and optimization flags * refactor: optimize quote detection in scalar and SIMD processing * refactor: clean up field flag definitions and remove unused quote detection functions
1 parent 5049b1e commit 7e116c6

5 files changed

Lines changed: 76 additions & 54 deletions

File tree

field_parser.go

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ type parserState struct {
3939
quoteAdjust uint64 // bytes to skip for opening quote (0 or 1)
4040
lastSepOrNewline int64 // last separator/newline position (-1 initially)
4141
lastClosingQuote int64 // last closing quote position (-1 if none)
42+
sawQuote bool // true if quote was seen in current field (for validation optimization)
4243
}
4344

4445
// newParserState creates an initialized parser state.
@@ -67,6 +68,7 @@ func (s *parserState) resetForNextField(delimiterPos uint64) {
6768
s.quoteAdjust = 0
6869
s.lastSepOrNewline = int64(delimiterPos)
6970
s.lastClosingQuote = -1
71+
s.sawQuote = false
7072
}
7173

7274
// =============================================================================
@@ -122,12 +124,13 @@ type fieldInfo struct {
122124
start uint32 // content start offset (after opening quote if quoted)
123125
length uint32 // content length (excluding quotes)
124126
rawEndDelta uint8 // delta from start+length to raw end position
125-
flags uint8 // bit0: needsUnescape, bit1: isQuoted
127+
flags uint8 // bit0: needsUnescape, bit1: isQuoted, bit2: containsQuote
126128
}
127129

128130
const (
129131
fieldFlagNeedsUnescape = 1 << 0
130132
fieldFlagIsQuoted = 1 << 1
133+
fieldFlagContainsQuote = 1 << 2 // field contains quote character (for validation optimization)
131134
)
132135

133136
// rawStart returns the raw start position (including opening quote if quoted).
@@ -144,11 +147,14 @@ func (f *fieldInfo) rawEnd() uint32 {
144147
}
145148

146149
// newFieldInfo creates a fieldInfo from parsed boundaries.
147-
func newFieldInfo(start, length uint64, rawEndDelta uint8, isQuoted bool) fieldInfo {
150+
func newFieldInfo(start, length uint64, rawEndDelta uint8, isQuoted, containsQuote bool) fieldInfo {
148151
var flags uint8
149152
if isQuoted {
150153
flags = fieldFlagIsQuoted
151154
}
155+
if containsQuote {
156+
flags |= fieldFlagContainsQuote
157+
}
152158
return fieldInfo{
153159
start: uint32(start),
154160
length: uint32(length),
@@ -171,6 +177,12 @@ func (f *fieldInfo) needsUnescape() bool {
171177
return f.flags&fieldFlagNeedsUnescape != 0
172178
}
173179

180+
// containsQuote returns whether the field contains any quote characters.
181+
// Used for validation optimization - fields without quotes don't need quote validation.
182+
func (f *fieldInfo) containsQuote() bool {
183+
return f.flags&fieldFlagContainsQuote != 0
184+
}
185+
174186
// rowInfo holds row metadata.
175187
type rowInfo struct {
176188
firstField int // index of first field in parseResult.fields
@@ -387,6 +399,7 @@ func classifyEvent(bit, quoteMask, sepMask uint64) eventType {
387399

388400
// handleQuoteEvent processes a quote character, toggling the quoted state.
389401
func handleQuoteEvent(absPos uint64, state *parserState) {
402+
state.sawQuote = true // Mark that this field contains a quote
390403
if state.quoted {
391404
state.exitQuotedState(absPos)
392405
} else {
@@ -430,6 +443,7 @@ func skipBlankLine(state *parserState, absPos uint64, lineNum *int) {
430443
state.fieldStart = absPos + 1
431444
state.quoteAdjust = 0
432445
state.lastClosingQuote = -1
446+
state.sawQuote = false
433447
(*lineNum)++
434448
}
435449

@@ -441,7 +455,8 @@ func skipBlankLine(state *parserState, absPos uint64, lineNum *int) {
441455
// For newline delimiters (isNewline=true), excludes trailing CR from CRLF sequences.
442456
func recordField(buf []byte, absPos uint64, state *parserState, result *parseResult, isNewline bool) {
443457
bounds := computeFieldBounds(buf, absPos, state, isNewline)
444-
result.fields = append(result.fields, newFieldInfo(bounds.start, bounds.length, bounds.rawEndDelta, bounds.isQuoted))
458+
containsQuote := state.sawQuote
459+
result.fields = append(result.fields, newFieldInfo(bounds.start, bounds.length, bounds.rawEndDelta, bounds.isQuoted, containsQuote))
445460
state.resetForNextField(absPos)
446461
}
447462

@@ -544,8 +559,9 @@ func finalizeLastField(buf []byte, state *parserState, result *parseResult, rowF
544559
fieldLen := computeFieldLength(bufLen, start, state)
545560
rawEndDelta := computeRawEndDelta(bufLen, start, fieldLen)
546561
isQuoted := state.quoteAdjust > 0
562+
containsQuote := state.sawQuote
547563

548-
result.fields = append(result.fields, newFieldInfo(start, fieldLen, rawEndDelta, isQuoted))
564+
result.fields = append(result.fields, newFieldInfo(start, fieldLen, rawEndDelta, isQuoted, containsQuote))
549565
result.rows = append(result.rows, rowInfo{
550566
firstField: rowFirstField,
551567
fieldCount: len(result.fields) - rowFirstField,

quote.go

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package simdcsv
44

55
import (
6+
"bytes"
67
"math/bits"
78

89
"simd/archsimd"
@@ -78,15 +79,17 @@ func findClosingQuote(data []byte, startAfterOpenQuote int) int {
7879

7980
// findClosingQuoteScalar finds the closing quote using scalar operations.
8081
func findClosingQuoteScalar(data []byte, startAfterOpenQuote int) int {
81-
for i := startAfterOpenQuote; i < len(data); i++ {
82-
if data[i] != '"' {
83-
continue
82+
for i := startAfterOpenQuote; i < len(data); {
83+
next := bytes.IndexByte(data[i:], '"')
84+
if next == -1 {
85+
return -1
8486
}
85-
if isEscapedQuote(data, i) {
86-
i++ // Skip second quote of escape sequence (loop increments again)
87+
pos := i + next
88+
if isEscapedQuote(data, pos) {
89+
i = pos + 2
8790
continue
8891
}
89-
return i
92+
return pos
9093
}
9194
return -1
9295
}
@@ -125,6 +128,21 @@ func findClosingQuoteSIMD(data []byte, startAfterOpenQuote int) int {
125128
// processQuoteMask processes quote positions in a SIMD chunk mask.
126129
// Returns (closingQuoteIdx, newPosition, shouldExitLoop).
127130
func processQuoteMask(data []byte, chunkStart int, mask uint64) (int, int, bool) {
131+
// Fast path: no adjacent quotes in this chunk, so first quote closes.
132+
if mask&(mask<<1) == 0 {
133+
pos := bits.TrailingZeros64(mask)
134+
if pos == simdChunkSize-1 {
135+
newPos := chunkStart + simdChunkSize
136+
if newPos < len(data) && data[newPos] == '"' {
137+
// Boundary double quote → skip both
138+
return -1, newPos + 1, false
139+
}
140+
// Closing quote at boundary
141+
return chunkStart + pos, chunkStart, false
142+
}
143+
return chunkStart + pos, chunkStart, false
144+
}
145+
128146
for mask != 0 {
129147
pos := bits.TrailingZeros64(mask)
130148

record_builder.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,11 +230,12 @@ func (r *Reader) validateFieldIfNeeded(field fieldInfo, lineNum int) error {
230230
return nil
231231
}
232232

233-
rawStart, rawEnd := uint64(field.rawStart()), uint64(field.rawEnd())
234-
if !r.fieldMayContainQuote(rawStart, rawEnd) {
233+
// Fast path: field doesn't contain any quotes (set during parsing)
234+
if !field.containsQuote() {
235235
return nil
236236
}
237237

238+
rawStart, rawEnd := uint64(field.rawStart()), uint64(field.rawEnd())
238239
return r.validateFieldQuotesWithField(field, rawStart, rawEnd, lineNum)
239240
}
240241

simd_scanner.go

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -337,28 +337,46 @@ func normalizeCRLF(crMask, nlMask, nextNlMask uint64, validBits int) uint64 {
337337
// separators inside quoted regions. Detects escaped double quotes ("") including
338338
// those spanning chunk boundaries.
339339
func processQuotesAndSeparators(quoteMask, sepMask, nextQuoteMask uint64, state *scanState) (quoteMaskOut, sepMaskOut uint64, hasDoubleQuote, boundaryDoubleQuote bool) {
340-
quoteMaskOut = quoteMask
341-
workQuote := quoteMask
342340
quoted := state.quoted
343341
initialQuoted := quoted
344342

345-
// Step 1: Process quotes to detect and remove double quotes
343+
// Fast path: no quotes in this chunk
344+
if quoteMask == 0 {
345+
if quoted == 0 {
346+
return 0, sepMask, false, false
347+
}
348+
// All separators are inside quoted region
349+
return 0, 0, false, false
350+
}
351+
352+
quoteMaskOut = quoteMask
353+
354+
// Pre-detect adjacent quote pairs (potential double quotes when inside quoted region)
355+
// adjacentPairs has bit set at the LEFT position of each adjacent pair
356+
adjacentPairs := quoteMask & (quoteMask >> 1)
357+
358+
// Pre-check for boundary double quote (quote at pos 63 with quote at pos 0 of next chunk)
359+
const lastBit = uint64(1) << 63
360+
boundaryCandidate := quoteMask&lastBit != 0 && nextQuoteMask&1 != 0
361+
362+
workQuote := quoteMask
346363
for workQuote != 0 {
347364
pos := bits.TrailingZeros64(workQuote)
348365
bit := uint64(1) << pos
349366

350367
if quoted != 0 {
351368
// Inside quotes: check for escaped double quote
352-
if pos == simdChunkSize-1 && nextQuoteMask&1 != 0 {
369+
if adjacentPairs&bit != 0 {
370+
// Adjacent double quote - remove both quotes from output
371+
nextBit := bit << 1
372+
quoteMaskOut &^= bit | nextBit
373+
hasDoubleQuote = true
374+
workQuote &^= nextBit // Skip next quote
375+
} else if pos == 63 && boundaryCandidate {
353376
// Boundary double quote
354-
quoteMaskOut &^= uint64(1) << (simdChunkSize - 1)
377+
quoteMaskOut &^= lastBit
355378
hasDoubleQuote = true
356379
boundaryDoubleQuote = true
357-
} else if pos < simdChunkSize-1 && workQuote&(uint64(1)<<(pos+1)) != 0 {
358-
// Adjacent double quote
359-
quoteMaskOut &^= uint64(3) << pos
360-
hasDoubleQuote = true
361-
workQuote &^= uint64(1) << (pos + 1)
362380
} else {
363381
// Closing quote
364382
quoted = 0
@@ -372,7 +390,7 @@ func processQuotesAndSeparators(quoteMask, sepMask, nextQuoteMask uint64, state
372390

373391
state.quoted = quoted
374392

375-
// Step 2: Invalidate separators using prefix XOR on clean quote mask
393+
// Invalidate separators using prefix XOR on cleaned quote mask
376394
inQuote := quoteMaskOut
377395
inQuote ^= inQuote << 1
378396
inQuote ^= inQuote << 2

validation.go

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -30,37 +30,6 @@ func (p validationPolicy) shouldUseMetadata(field fieldInfo) bool {
3030
return field.flags&fieldFlagIsQuoted != 0 && !p.trimLeadingSpace
3131
}
3232

33-
// =============================================================================
34-
// Chunk-Level Quote Detection - Fast path optimization
35-
// =============================================================================
36-
37-
// fieldMayContainQuote reports whether any chunk overlapped by the field contains a quote.
38-
// Returns true conservatively when chunk data is unavailable.
39-
func (r *Reader) fieldMayContainQuote(rawStart, rawEnd uint64) bool {
40-
if len(r.state.chunkHasQuote) == 0 {
41-
return true // Conservative: assume quotes when no chunk data
42-
}
43-
if rawEnd <= rawStart {
44-
return false // Empty range contains nothing
45-
}
46-
47-
startChunk := int(rawStart / simdChunkSize) //nolint:gosec // G115
48-
endChunk := int((rawEnd - 1) / simdChunkSize) //nolint:gosec // G115
49-
endChunk = min(endChunk, len(r.state.chunkHasQuote)-1)
50-
51-
return anyChunkHasQuote(r.state.chunkHasQuote, startChunk, endChunk)
52-
}
53-
54-
// anyChunkHasQuote checks if any chunk in the range [start, end] contains a quote.
55-
func anyChunkHasQuote(chunks []bool, start, end int) bool {
56-
for i := start; i <= end; i++ {
57-
if chunks[i] {
58-
return true
59-
}
60-
}
61-
return false
62-
}
63-
6433
// =============================================================================
6534
// Field Extraction - Mechanism for accessing raw field data
6635
// =============================================================================

0 commit comments

Comments
 (0)