Skip to content

Commit 9debfe1

Browse files
committed
copy the core of Papaparse as an iterator on rows
1 parent 29a2de7 commit 9debfe1

17 files changed

Lines changed: 731 additions & 104 deletions

src/chunk.ts

Lines changed: 62 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,85 @@
1-
import { getDelimiter } from './delimiter'
2-
import { getNewline } from './newline'
3-
import { getQuote } from './quote'
4-
5-
export interface ChunkResult {
6-
data: string[]
7-
metadata: {
8-
byteCount: number
9-
offset: number
10-
delimiter: string
11-
newline: string
12-
quote: string
13-
}
14-
}
1+
import { DefaultDelimiter } from './constants'
2+
import { guessDelimiter, validateDelimiter } from './delimiter'
3+
import { guessLineEndings, validateNewline } from './newline'
4+
import { parse } from './parser'
5+
import { validateQuoteChar } from './quoteChar'
6+
import type { ParseResult } from './types'
7+
import { testEmptyLine } from './utils'
158

169
/**
1710
* Parses a chunk of bytes into CSV data.
1811
* @param options Options for parsing the chunk.
1912
* @param options.bytes The chunk of bytes to parse.
2013
* @param options.delimiter The delimiter used in the CSV data. Defaults to ','.
2114
* @param options.newline The newline used in the CSV data. Defaults to '\n'.
22-
* @param options.quote The quote character used in the CSV data. Defaults to '"'.
15+
* @param options.quoteChar The quote character used in the CSV data. Defaults to '"'.
16+
* @param options.comments The comment character or boolean to indicate comments
17+
* @param options.delimitersToGuess The list of delimiters to guess from
18+
* @param options.skipEmptyLines Whether to skip empty lines, if so, whether 'greedy' or not. Defaults to false.
19+
* @param options.ignoreLastRow Whether to ignore the last row. Defaults to false.
2320
* @yields Parsed data and metadata.
2421
* @returns A generator yielding parsed data and metadata.
2522
*/
2623
export function* parseChunk({
2724
bytes,
2825
delimiter,
2926
newline,
30-
quote,
27+
quoteChar,
28+
comments,
29+
delimitersToGuess,
30+
skipEmptyLines,
31+
ignoreLastRow,
3132
}: {
3233
bytes: Uint8Array
3334
delimiter?: string
3435
newline?: string
35-
quote?: string
36-
}): Generator<ChunkResult, void, unknown> {
37-
delimiter ??= getDelimiter()
38-
newline ??= getNewline()
39-
quote ??= getQuote()
40-
41-
// TODO(SL): reuse decoder?
36+
quoteChar?: string
37+
comments?: boolean | string
38+
delimitersToGuess?: string[]
39+
skipEmptyLines?: boolean | 'greedy'
40+
ignoreLastRow?: boolean
41+
}): Generator<ParseResult, void, unknown> {
4242
const decoder = new TextDecoder('utf-8')
43+
const input = decoder.decode(bytes)
44+
45+
skipEmptyLines ??= false
46+
quoteChar = validateQuoteChar(quoteChar)
47+
newline = validateNewline(newline) ?? guessLineEndings(input, quoteChar)
48+
49+
let delimiterError = false
50+
delimiter = validateDelimiter(delimiter)
51+
if (!delimiter) {
52+
const delimGuess = guessDelimiter(input, newline, skipEmptyLines, comments, delimitersToGuess)
53+
if (delimGuess.successful)
54+
delimiter = delimGuess.bestDelimiter
55+
else {
56+
delimiterError = true // add error after first row parsing
57+
delimiter = DefaultDelimiter
58+
}
59+
}
60+
61+
for (const result of parse(input, {
62+
delimiter,
63+
newline,
64+
quoteChar,
65+
ignoreLastRow,
66+
comments,
67+
// TODO(SL): add escapeChar?
68+
})) {
69+
if (delimiterError) {
70+
result.errors.push({
71+
type: 'Delimiter',
72+
code: 'UndetectableDelimiter',
73+
message: 'Unable to auto-detect delimiting character; defaulted to \'' + DefaultDelimiter + '\'',
74+
})
75+
delimiterError = false
76+
}
4377

44-
const text = decoder.decode(bytes)
78+
if (skipEmptyLines && testEmptyLine(result.row, skipEmptyLines)) {
79+
// TODO(SL) accumulate the byte count of removed lines
80+
continue
81+
}
4582

46-
yield {
47-
data: [text],
48-
metadata: {
49-
byteCount: bytes.length,
50-
offset: 0,
51-
delimiter,
52-
newline,
53-
quote,
54-
},
83+
yield result
5584
}
5685
}

src/comments.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { BAD_DELIMITERS } from './constants'
2+
3+
/**
4+
* Validates the comment character
5+
* @param comments The comment character or boolean to indicate comments
6+
* @param delimiter The delimiter character
7+
* @returns The comment character.
8+
*/
9+
export function validateComments(comments?: string | boolean, delimiter?: string): undefined | string | false {
10+
if (comments === undefined || comments === false) {
11+
return comments
12+
}
13+
if (comments === true) {
14+
return '#'
15+
}
16+
if (delimiter !== undefined && comments === delimiter) {
17+
throw new Error('Comment character same as delimiter')
18+
}
19+
if (BAD_DELIMITERS.includes(comments)) {
20+
throw new Error(`Invalid comment character: ${comments}`)
21+
}
22+
return comments
23+
}

src/constants.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,8 @@
11
export const defaultChunkSize = 1024 * 1024 // 1MB
2+
3+
export const RECORD_SEP = String.fromCharCode(30)
4+
export const UNIT_SEP = String.fromCharCode(31)
5+
export const BYTE_ORDER_MARK = '\ufeff'
6+
export const BAD_DELIMITERS = ['\r', '\n', '"', BYTE_ORDER_MARK]
7+
8+
export const DefaultDelimiter = ',' // Used if not specified and detection fails

src/delimiter.ts

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,89 @@
1+
import { BAD_DELIMITERS, RECORD_SEP, UNIT_SEP } from './constants'
2+
import { parse } from './parser'
3+
import { testEmptyLine } from './utils'
4+
15
/**
2-
* Returns the delimiter used in the CSV file.
6+
* Validates the delimiter
7+
* @param delimiter The delimiter string
38
* @returns The delimiter string.
49
*/
5-
export function getDelimiter() {
6-
// TODO(SL): make the delimiter configurable
7-
// TODO(SL): guess the delimiter from the first chunk
8-
return ','
10+
export function validateDelimiter(delimiter?: string): undefined | string {
11+
if (delimiter === undefined) {
12+
return undefined
13+
}
14+
if (BAD_DELIMITERS.includes(delimiter)) {
15+
throw new Error(`Invalid delimiter: ${delimiter}`)
16+
}
17+
return delimiter
18+
}
19+
20+
/**
21+
* Guess the delimiter
22+
* @param input The input string
23+
* @param newline The newline character
24+
* @param skipEmptyLines Whether to skip empty lines, if so, whether 'greedy' or not
25+
* @param comments The comment character or boolean to indicate comments
26+
* @param delimitersToGuess The list of delimiters to guess from
27+
* @returns An object indicating whether guessing was successful and the best delimiter found
28+
*/
29+
export function guessDelimiter(input: string, newline?: string, skipEmptyLines?: boolean | 'greedy', comments?: boolean | string, delimitersToGuess?: string[]) {
30+
let bestDelimiter, bestDelta, maxFieldCount
31+
32+
delimitersToGuess = delimitersToGuess || [',', '\t', '|', ';', RECORD_SEP, UNIT_SEP]
33+
34+
for (let i = 0; i < delimitersToGuess.length; i++) {
35+
const delimiter = delimitersToGuess[i]
36+
let delta = 0
37+
let nonEmptyLinesCount = 0
38+
let avgFieldCount = 0
39+
let fieldCountPrevRow: number | undefined
40+
let j = 0
41+
const previewLines = 10
42+
43+
for (const { row } of parse(input, {
44+
delimiter,
45+
newline,
46+
comments,
47+
ignoreLastRow: false,
48+
})) {
49+
if (j >= previewLines) {
50+
break
51+
}
52+
if (skipEmptyLines && testEmptyLine(row, skipEmptyLines)) {
53+
continue
54+
}
55+
nonEmptyLinesCount++
56+
57+
const fieldCount = row.length
58+
avgFieldCount += fieldCount
59+
60+
if (fieldCountPrevRow === undefined) {
61+
fieldCountPrevRow = fieldCount
62+
continue
63+
}
64+
else if (fieldCount > 0) {
65+
delta += Math.abs(fieldCount - fieldCountPrevRow)
66+
fieldCountPrevRow = fieldCount
67+
}
68+
j++
69+
}
70+
71+
if (nonEmptyLinesCount > 0)
72+
avgFieldCount /= (nonEmptyLinesCount)
73+
74+
if (
75+
(bestDelta === undefined || delta <= bestDelta)
76+
&& (maxFieldCount === undefined || avgFieldCount > maxFieldCount)
77+
&& avgFieldCount > 1.99
78+
) {
79+
bestDelta = delta
80+
bestDelimiter = delimiter
81+
maxFieldCount = avgFieldCount
82+
}
83+
}
84+
85+
return {
86+
successful: !!bestDelimiter,
87+
bestDelimiter: bestDelimiter,
88+
}
989
}

src/newline.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,53 @@
1+
import { escapeRegExp } from './utils'
2+
3+
type Newline = '\n' | '\r' | '\r\n'
4+
15
/**
26
* Returns the newline string to be used in parsing.
7+
* @param newline The newline string to validate.
38
* @returns The newline string.
49
*/
5-
export function getNewline() {
10+
export function validateNewline(newline?: string): Newline | undefined {
611
// TODO(SL): make the newline configurable
712
// TODO(SL): guess the newline from the first chunk
8-
return '\n'
13+
if (newline === undefined) {
14+
return undefined
15+
}
16+
if (newline !== '\n' && newline !== '\r' && newline !== '\r\n') {
17+
throw new Error(`Invalid newline: ${newline}`)
18+
}
19+
return newline
20+
}
21+
22+
/**
23+
* Guess the line endings
24+
* @param input The input string
25+
* @param quoteChar The quote character
26+
* @returns The line ending character
27+
*/
28+
export function guessLineEndings(input: string, quoteChar: string): Newline {
29+
input = input.substring(0, 1024 * 1024) // max length 1 MB
30+
// Replace all the text inside quotes
31+
const re = new RegExp(escapeRegExp(quoteChar) + '([^]*?)' + escapeRegExp(quoteChar), 'gm')
32+
input = input.replace(re, '')
33+
34+
const r = input.split('\r')
35+
36+
const n = input.split('\n')
37+
38+
if (!(0 in r && 0 in n)) {
39+
throw new Error('r or n should have at least one element')
40+
}
41+
const nAppearsFirst = (n.length > 1 && n[0].length < r[0].length)
42+
43+
if (r.length === 1 || nAppearsFirst)
44+
return '\n'
45+
46+
let numWithN = 0
47+
for (const match of r) {
48+
if (match[0] === '\n')
49+
numWithN++
50+
}
51+
52+
return numWithN >= r.length / 2 ? '\r\n' : '\r'
953
}

0 commit comments

Comments
 (0)