Skip to content

Commit 57f4687

Browse files
authored
refactor: split functions and move them (#16)
* refactor: split functions and move them * chore: fix lint
1 parent ed3d20d commit 57f4687

8 files changed

Lines changed: 1057 additions & 429 deletions

File tree

parse.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
//go:build goexperiment.simd && amd64
2+
3+
package simdcsv
4+
5+
// ParseBytes parses a byte slice directly (zero-copy).
6+
// This function runs Stage 1 and Stage 2 processing and returns all records.
7+
func ParseBytes(data []byte, comma rune) ([][]string, error) {
8+
if len(data) == 0 {
9+
return nil, nil
10+
}
11+
12+
// Stage 1: Structural analysis using SIMD (generates bitmasks)
13+
separatorChar := byte(comma)
14+
sr := scanBuffer(data, separatorChar)
15+
16+
// Stage 2: Extract fields and rows from scan result
17+
pr := parseBuffer(data, sr)
18+
19+
// Stage 3: Convert parseResult to [][]string
20+
return buildRecords(data, pr), nil
21+
}
22+
23+
// ParseBytesStreaming parses data using a streaming callback function.
24+
// The callback is invoked for each record parsed from the input.
25+
// If the callback returns an error, parsing stops and that error is returned.
26+
func ParseBytesStreaming(data []byte, comma rune, callback func([]string) error) error {
27+
if len(data) == 0 {
28+
return nil
29+
}
30+
31+
// Stage 1: Structural analysis using SIMD (generates bitmasks)
32+
separatorChar := byte(comma)
33+
sr := scanBuffer(data, separatorChar)
34+
35+
// Stage 2: Extract fields and rows from scan result
36+
pr := parseBuffer(data, sr)
37+
38+
if pr == nil || len(pr.rows) == 0 {
39+
return nil
40+
}
41+
42+
// Stage 3: Invoke callback for each record
43+
for _, row := range pr.rows {
44+
record := buildRecord(data, pr, row)
45+
if err := callback(record); err != nil {
46+
return err
47+
}
48+
}
49+
return nil
50+
}
51+
52+
// buildRecords converts a parseResult to [][]string.
53+
func buildRecords(buf []byte, pr *parseResult) [][]string {
54+
if pr == nil || len(pr.rows) == 0 {
55+
return nil
56+
}
57+
58+
records := make([][]string, len(pr.rows))
59+
for rowIdx, row := range pr.rows {
60+
records[rowIdx] = buildRecord(buf, pr, row)
61+
}
62+
return records
63+
}
64+
65+
// buildRecord builds a single record from a rowInfo.
66+
func buildRecord(buf []byte, pr *parseResult, row rowInfo) []string {
67+
record := make([]string, row.fieldCount)
68+
for i := 0; i < row.fieldCount; i++ {
69+
fieldIdx := row.firstField + i
70+
if fieldIdx >= len(pr.fields) {
71+
break
72+
}
73+
record[i] = extractField(buf, pr.fields[fieldIdx])
74+
}
75+
return record
76+
}

parse_test.go

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
//go:build goexperiment.simd && amd64
2+
3+
package simdcsv
4+
5+
import (
6+
"encoding/csv"
7+
"io"
8+
"reflect"
9+
"strings"
10+
"testing"
11+
)
12+
13+
// =============================================================================
14+
// ParseBytes Tests
15+
// =============================================================================
16+
17+
// TestParseBytes_Basic tests the ParseBytes function with various inputs.
18+
func TestParseBytes_Basic(t *testing.T) {
19+
tests := []struct {
20+
name string
21+
input string
22+
want [][]string
23+
}{
24+
{
25+
name: "simple CSV",
26+
input: "a,b,c\n1,2,3\n",
27+
want: [][]string{{"a", "b", "c"}, {"1", "2", "3"}},
28+
},
29+
{
30+
name: "empty input",
31+
input: "",
32+
want: nil,
33+
},
34+
{
35+
name: "single field",
36+
input: "hello\n",
37+
want: [][]string{{"hello"}},
38+
},
39+
{
40+
name: "quoted fields",
41+
input: `"a","b,c","d"` + "\n",
42+
want: [][]string{{"a", "b,c", "d"}},
43+
},
44+
{
45+
name: "double quotes",
46+
input: `"he said ""hello"""` + "\n",
47+
want: [][]string{{`he said "hello"`}},
48+
},
49+
{
50+
name: "no trailing newline",
51+
input: "a,b,c",
52+
want: [][]string{{"a", "b", "c"}},
53+
},
54+
{
55+
name: "multiline field",
56+
input: "\"hello\nworld\",b\n",
57+
want: [][]string{{"hello\nworld", "b"}},
58+
},
59+
}
60+
61+
for _, tt := range tests {
62+
t.Run(tt.name, func(t *testing.T) {
63+
got, err := ParseBytes([]byte(tt.input), ',')
64+
if err != nil {
65+
t.Fatalf("ParseBytes error: %v", err)
66+
}
67+
68+
if !reflect.DeepEqual(got, tt.want) {
69+
t.Errorf("ParseBytes mismatch:\ngot=%v\nwant=%v", got, tt.want)
70+
}
71+
72+
// Also compare with encoding/csv
73+
stdReader := csv.NewReader(strings.NewReader(tt.input))
74+
stdReader.FieldsPerRecord = -1
75+
stdRecords, stdErr := stdReader.ReadAll()
76+
if stdErr != nil {
77+
t.Fatalf("encoding/csv error: %v", stdErr)
78+
}
79+
80+
if !reflect.DeepEqual(got, stdRecords) {
81+
t.Errorf("ParseBytes vs encoding/csv mismatch:\nParseBytes=%v\nencoding/csv=%v", got, stdRecords)
82+
}
83+
})
84+
}
85+
}
86+
87+
// TestParseBytes_CustomSeparator tests ParseBytes with custom separators.
88+
func TestParseBytes_CustomSeparator(t *testing.T) {
89+
tests := []struct {
90+
name string
91+
input string
92+
comma rune
93+
want [][]string
94+
}{
95+
{
96+
name: "tab separator",
97+
input: "a\tb\tc\n",
98+
comma: '\t',
99+
want: [][]string{{"a", "b", "c"}},
100+
},
101+
{
102+
name: "semicolon separator",
103+
input: "a;b;c\n",
104+
comma: ';',
105+
want: [][]string{{"a", "b", "c"}},
106+
},
107+
}
108+
109+
for _, tt := range tests {
110+
t.Run(tt.name, func(t *testing.T) {
111+
got, err := ParseBytes([]byte(tt.input), tt.comma)
112+
if err != nil {
113+
t.Fatalf("ParseBytes error: %v", err)
114+
}
115+
116+
if !reflect.DeepEqual(got, tt.want) {
117+
t.Errorf("ParseBytes mismatch:\ngot=%v\nwant=%v", got, tt.want)
118+
}
119+
})
120+
}
121+
}
122+
123+
// =============================================================================
124+
// ParseBytesStreaming Tests
125+
// =============================================================================
126+
127+
// TestParseBytesStreaming_Basic tests the ParseBytesStreaming function.
128+
func TestParseBytesStreaming_Basic(t *testing.T) {
129+
tests := []struct {
130+
name string
131+
input string
132+
want [][]string
133+
}{
134+
{
135+
name: "simple CSV",
136+
input: "a,b,c\n1,2,3\n",
137+
want: [][]string{{"a", "b", "c"}, {"1", "2", "3"}},
138+
},
139+
{
140+
name: "empty input",
141+
input: "",
142+
want: nil,
143+
},
144+
{
145+
name: "single field",
146+
input: "hello\n",
147+
want: [][]string{{"hello"}},
148+
},
149+
{
150+
name: "quoted fields",
151+
input: `"a","b,c","d"` + "\n",
152+
want: [][]string{{"a", "b,c", "d"}},
153+
},
154+
{
155+
name: "multiline field",
156+
input: "\"hello\nworld\",b\n",
157+
want: [][]string{{"hello\nworld", "b"}},
158+
},
159+
}
160+
161+
for _, tt := range tests {
162+
t.Run(tt.name, func(t *testing.T) {
163+
var got [][]string
164+
err := ParseBytesStreaming([]byte(tt.input), ',', func(record []string) error {
165+
// Make a copy to avoid slice reuse issues
166+
recordCopy := make([]string, len(record))
167+
copy(recordCopy, record)
168+
got = append(got, recordCopy)
169+
return nil
170+
})
171+
if err != nil {
172+
t.Fatalf("ParseBytesStreaming error: %v", err)
173+
}
174+
175+
if !reflect.DeepEqual(got, tt.want) {
176+
t.Errorf("ParseBytesStreaming mismatch:\ngot=%v\nwant=%v", got, tt.want)
177+
}
178+
179+
// Compare with ParseBytes
180+
pbResult, pbErr := ParseBytes([]byte(tt.input), ',')
181+
if pbErr != nil {
182+
t.Fatalf("ParseBytes error: %v", pbErr)
183+
}
184+
if !reflect.DeepEqual(got, pbResult) {
185+
t.Errorf("ParseBytesStreaming vs ParseBytes mismatch:\nStreaming=%v\nParseBytes=%v", got, pbResult)
186+
}
187+
})
188+
}
189+
}
190+
191+
// TestParseBytesStreaming_CallbackError tests that callback errors are propagated.
192+
func TestParseBytesStreaming_CallbackError(t *testing.T) {
193+
input := "a,b\nc,d\ne,f\n"
194+
expectedErr := io.EOF // Use a recognizable error
195+
196+
callCount := 0
197+
err := ParseBytesStreaming([]byte(input), ',', func(record []string) error {
198+
callCount++
199+
if callCount == 2 {
200+
return expectedErr
201+
}
202+
return nil
203+
})
204+
205+
if err != expectedErr {
206+
t.Errorf("Expected error %v, got %v", expectedErr, err)
207+
}
208+
if callCount != 2 {
209+
t.Errorf("Expected callback to be called 2 times, got %d", callCount)
210+
}
211+
}
212+
213+
// TestParseBytesStreaming_CustomSeparator tests with custom separators.
214+
func TestParseBytesStreaming_CustomSeparator(t *testing.T) {
215+
input := "a\tb\tc\n1\t2\t3\n"
216+
want := [][]string{{"a", "b", "c"}, {"1", "2", "3"}}
217+
218+
var got [][]string
219+
err := ParseBytesStreaming([]byte(input), '\t', func(record []string) error {
220+
recordCopy := make([]string, len(record))
221+
copy(recordCopy, record)
222+
got = append(got, recordCopy)
223+
return nil
224+
})
225+
if err != nil {
226+
t.Fatalf("ParseBytesStreaming error: %v", err)
227+
}
228+
229+
if !reflect.DeepEqual(got, want) {
230+
t.Errorf("ParseBytesStreaming mismatch:\ngot=%v\nwant=%v", got, want)
231+
}
232+
}
233+
234+
// =============================================================================
235+
// buildRecords Tests
236+
// =============================================================================
237+
238+
func TestBuildRecords_Nil(t *testing.T) {
239+
result := buildRecords(nil, nil)
240+
if result != nil {
241+
t.Errorf("buildRecords(nil, nil) = %v, want nil", result)
242+
}
243+
}
244+
245+
func TestBuildRecords_EmptyRows(t *testing.T) {
246+
pr := &parseResult{
247+
fields: []fieldInfo{},
248+
rows: []rowInfo{},
249+
}
250+
result := buildRecords([]byte(""), pr)
251+
if result != nil {
252+
t.Errorf("buildRecords with empty rows = %v, want nil", result)
253+
}
254+
}
255+
256+
// =============================================================================
257+
// buildRecord Tests
258+
// =============================================================================
259+
260+
func TestBuildRecord(t *testing.T) {
261+
buf := []byte("hello,world\n")
262+
pr := &parseResult{
263+
fields: []fieldInfo{
264+
{start: 0, length: 5},
265+
{start: 6, length: 5},
266+
},
267+
rows: []rowInfo{
268+
{firstField: 0, fieldCount: 2, lineNum: 1},
269+
},
270+
}
271+
272+
record := buildRecord(buf, pr, pr.rows[0])
273+
274+
if len(record) != 2 {
275+
t.Fatalf("expected 2 fields, got %d", len(record))
276+
}
277+
if record[0] != "hello" {
278+
t.Errorf("field 0 = %q, want %q", record[0], "hello")
279+
}
280+
if record[1] != "world" {
281+
t.Errorf("field 1 = %q, want %q", record[1], "world")
282+
}
283+
}

0 commit comments

Comments
 (0)