@rip-lang/csv 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +240 -0
- package/csv.rip +437 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
<img src="https://raw.githubusercontent.com/shreeve/rip-lang/main/docs/rip.svg" style="width:50px" /> <br>
|
|
2
|
+
|
|
3
|
+
# Rip CSV - @rip-lang/csv
|
|
4
|
+
|
|
5
|
+
> **Fast, flexible CSV parser and writer — indexOf ratchet engine, auto-detection, zero dependencies**
|
|
6
|
+
|
|
7
|
+
A high-performance CSV library for Rip that uses the JavaScript engine's
|
|
8
|
+
SIMD-accelerated `indexOf` to skip over content in bulk. Auto-detects
|
|
9
|
+
delimiters, quoting, escaping, BOM, and line endings. Supports excel mode,
|
|
10
|
+
relax mode, headers, comments, streaming via row callback, and reusable
|
|
11
|
+
writer instances. ~300 lines of Rip, zero dependencies.
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bun add @rip-lang/csv
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```coffee
|
|
20
|
+
import { CSV } from '@rip-lang/csv'
|
|
21
|
+
|
|
22
|
+
# Parse a string
|
|
23
|
+
rows = CSV.read "name,age\nAlice,30\nBob,25\n"
|
|
24
|
+
# [['name','age'], ['Alice','30'], ['Bob','25']]
|
|
25
|
+
|
|
26
|
+
# Parse with headers (returns objects)
|
|
27
|
+
users = CSV.read "name,age\nAlice,30\nBob,25\n", headers: true
|
|
28
|
+
# [{name: 'Alice', age: '30'}, {name: 'Bob', age: '25'}]
|
|
29
|
+
|
|
30
|
+
# Parse a file
|
|
31
|
+
data = CSV.load! 'data.csv'
|
|
32
|
+
data = CSV.load! 'data.csv', headers: true
|
|
33
|
+
|
|
34
|
+
# Write CSV
|
|
35
|
+
str = CSV.write [['a','b'], ['1','2']]
|
|
36
|
+
# "a,b\n1,2\n"
|
|
37
|
+
|
|
38
|
+
# Write to file
|
|
39
|
+
CSV.save! 'out.csv', rows
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## How It Works
|
|
43
|
+
|
|
44
|
+
The parser uses an **indexOf ratchet** — a technique where the JavaScript
|
|
45
|
+
engine's native `indexOf` (backed by SIMD instructions in V8 and JSC) does
|
|
46
|
+
the heavy lifting. Instead of inspecting every character, the parser calls
|
|
47
|
+
`indexOf` to jump directly to the next delimiter, newline, or quote. Each
|
|
48
|
+
call can skip hundreds of bytes in a single native operation.
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
Source string: "Alice,30,New York\nBob,25,Chicago\n..."
|
|
52
|
+
↑ ↑ ↑ ↑
|
|
53
|
+
│ │ │ └── indexOf('\n') jumps here
|
|
54
|
+
│ │ └── indexOf(',') jumps here
|
|
55
|
+
│ └── indexOf(',') jumps here
|
|
56
|
+
└── start
|
|
57
|
+
|
|
58
|
+
Each indexOf call skips bulk content via SIMD — no per-byte scanning in JS.
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The parser has two code paths, selected at startup by probing the first ~8KB:
|
|
62
|
+
|
|
63
|
+
- **Fast path** — no quotes detected: pure indexOf for separators and newlines
|
|
64
|
+
- **Full path** — quotes present: indexOf ratchet with quote/escape handling
|
|
65
|
+
|
|
66
|
+
## Reading
|
|
67
|
+
|
|
68
|
+
### Basic Parsing
|
|
69
|
+
|
|
70
|
+
```coffee
|
|
71
|
+
# Auto-detects delimiter, quoting, line endings
|
|
72
|
+
rows = CSV.read str
|
|
73
|
+
|
|
74
|
+
# Tab-separated, pipe-separated — auto-detected
|
|
75
|
+
rows = CSV.read "a\tb\tc\n1\t2\t3\n"
|
|
76
|
+
rows = CSV.read "a|b|c\n1|2|3\n"
|
|
77
|
+
|
|
78
|
+
# Explicit separator
|
|
79
|
+
rows = CSV.read str, sep: ';'
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Headers Mode
|
|
83
|
+
|
|
84
|
+
```coffee
|
|
85
|
+
# First row becomes object keys
|
|
86
|
+
users = CSV.read str, headers: true
|
|
87
|
+
# [{name: 'Alice', age: '30'}, ...]
|
|
88
|
+
|
|
89
|
+
console.log users[0].name # "Alice"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Row-by-Row Processing
|
|
93
|
+
|
|
94
|
+
```coffee
|
|
95
|
+
# Process rows one at a time without building an array
|
|
96
|
+
count = CSV.read str, each: (row, index) ->
|
|
97
|
+
console.log "Row #{index}: #{row}"
|
|
98
|
+
|
|
99
|
+
# Early halt by returning false
|
|
100
|
+
CSV.read str, each: (row) ->
|
|
101
|
+
if row[0] is 'STOP'
|
|
102
|
+
return false
|
|
103
|
+
process(row)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### File I/O
|
|
107
|
+
|
|
108
|
+
```coffee
|
|
109
|
+
# Read a file (async)
|
|
110
|
+
rows = CSV.load! 'data.csv'
|
|
111
|
+
rows = CSV.load! 'data.csv', headers: true, strip: true
|
|
112
|
+
|
|
113
|
+
# Row-by-row file processing
|
|
114
|
+
CSV.load! 'huge.csv', each: (row) -> db.insert!(row)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Excel Mode
|
|
118
|
+
|
|
119
|
+
```coffee
|
|
120
|
+
# Handles ="01" literals (preserves leading zeros)
|
|
121
|
+
rows = CSV.read '="01",hello\n', excel: true
|
|
122
|
+
# [['01', 'hello']]
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Relax Mode
|
|
126
|
+
|
|
127
|
+
```coffee
|
|
128
|
+
# Recovers from stray/unmatched quotes instead of throwing
|
|
129
|
+
rows = CSV.read str, relax: true
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Writing
|
|
133
|
+
|
|
134
|
+
### Basic Writing
|
|
135
|
+
|
|
136
|
+
```coffee
|
|
137
|
+
str = CSV.write [['name','age'], ['Alice','30']]
|
|
138
|
+
# "name,age\nAlice,30\n"
|
|
139
|
+
|
|
140
|
+
# Write to file (async)
|
|
141
|
+
CSV.save! 'out.csv', rows
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Format a Single Row
|
|
145
|
+
|
|
146
|
+
```coffee
|
|
147
|
+
line = CSV.formatRow ['Alice', 'New York, NY', '30']
|
|
148
|
+
# 'Alice,"New York, NY",30'
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Reusable Writer
|
|
152
|
+
|
|
153
|
+
```coffee
|
|
154
|
+
w = CSV.writer(sep: '\t', excel: true)
|
|
155
|
+
|
|
156
|
+
for record in records
|
|
157
|
+
line = w.row(record)
|
|
158
|
+
stream.write "#{line}\n"
|
|
159
|
+
|
|
160
|
+
# Or format all at once
|
|
161
|
+
output = w.rows(records)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Writer Modes
|
|
165
|
+
|
|
166
|
+
```coffee
|
|
167
|
+
# Compact (default): quote only when necessary
|
|
168
|
+
CSV.write rows, mode: 'compact'
|
|
169
|
+
|
|
170
|
+
# Full: quote every field
|
|
171
|
+
CSV.write rows, mode: 'full'
|
|
172
|
+
|
|
173
|
+
# Excel: emit ="0123" for leading-zero numbers
|
|
174
|
+
CSV.write rows, excel: true
|
|
175
|
+
|
|
176
|
+
# Drop trailing empty columns
|
|
177
|
+
CSV.write rows, drop: true
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Options Reference
|
|
181
|
+
|
|
182
|
+
### Reader Options
|
|
183
|
+
|
|
184
|
+
| Option | Type | Default | Description |
|
|
185
|
+
|--------|------|---------|-------------|
|
|
186
|
+
| `sep` | string | auto | Field delimiter (`,` `\t` `\|` `;` or any string) |
|
|
187
|
+
| `quote` | string | `"` | Quote/enclosure character |
|
|
188
|
+
| `escape` | string | same as `quote` | Escape character (`"` for doubled, `\` for backslash) |
|
|
189
|
+
| `headers` | boolean | `false` | First row as keys — return objects |
|
|
190
|
+
| `excel` | boolean | `false` | Handle `="01"` literals |
|
|
191
|
+
| `relax` | boolean | `false` | Recover from stray quotes |
|
|
192
|
+
| `strip` | boolean | `false` | Trim whitespace from fields |
|
|
193
|
+
| `comments` | string | `null` | Skip lines starting with this character |
|
|
194
|
+
| `skipBlanks` | boolean | `true` | Skip blank lines |
|
|
195
|
+
| `row` | string | auto | Line ending override (`\n`, `\r\n`, `\r`) |
|
|
196
|
+
| `each` | function | `null` | `(row, index) ->` callback per row |
|
|
197
|
+
|
|
198
|
+
### Writer Options
|
|
199
|
+
|
|
200
|
+
| Option | Type | Default | Description |
|
|
201
|
+
|--------|------|---------|-------------|
|
|
202
|
+
| `sep` | string | `','` | Field delimiter |
|
|
203
|
+
| `quote` | string | `'"'` | Quote character |
|
|
204
|
+
| `escape` | string | same as `quote` | Escape character |
|
|
205
|
+
| `mode` | string | `'compact'` | `'compact'` or `'full'` |
|
|
206
|
+
| `excel` | boolean | `false` | Emit `="0123"` for leading zeros |
|
|
207
|
+
| `drop` | boolean | `false` | Drop trailing empty columns |
|
|
208
|
+
| `rowsep` | string | `'\n'` | Row separator |
|
|
209
|
+
|
|
210
|
+
> **Note:** The writer defaults to doubled-quote escaping (`""`). Pass
|
|
211
|
+
> `escape: '\\'` for backslash style.
|
|
212
|
+
|
|
213
|
+
## Auto-Detection
|
|
214
|
+
|
|
215
|
+
When you call `CSV.read(str)` with no options, the probe function scans the
|
|
216
|
+
first ~8KB to automatically detect:
|
|
217
|
+
|
|
218
|
+
- **BOM** — strips UTF-8 BOM if present
|
|
219
|
+
- **`sep=` header** — Excel convention for declaring delimiter
|
|
220
|
+
- **Delimiter** — tries `,` `\t` `|` `;`, picks the most frequent
|
|
221
|
+
- **Quote character** — detects if `"` appears in the sample
|
|
222
|
+
- **Escape style** — `\"` (backslash) vs `""` (doubled quote)
|
|
223
|
+
- **Line endings** — `\r\n`, `\n`, or `\r`
|
|
224
|
+
|
|
225
|
+
User options override any probed value.
|
|
226
|
+
|
|
227
|
+
## API Summary
|
|
228
|
+
|
|
229
|
+
```coffee
|
|
230
|
+
CSV.read(str, opts) # parse string -> rows or objects
|
|
231
|
+
CSV.load!(path, opts) # parse file (async)
|
|
232
|
+
CSV.write(rows, opts) # format rows -> CSV string
|
|
233
|
+
CSV.save!(path, rows, opts) # write to file (async)
|
|
234
|
+
CSV.writer(opts) # create reusable Writer instance
|
|
235
|
+
CSV.formatRow(row, opts) # format single row -> string
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## License
|
|
239
|
+
|
|
240
|
+
MIT
|
package/csv.rip
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# csv — Fast, flexible CSV parser and writer for Rip
|
|
3
|
+
#
|
|
4
|
+
# Author: Steve Shreeve (steve.shreeve@gmail.com)
|
|
5
|
+
# Date: February 6, 2026
|
|
6
|
+
#
|
|
7
|
+
# Engine: indexOf ratchet — SIMD-accelerated scanning via the JS engine's
|
|
8
|
+
# native indexOf, skipping bulk content in a single call. No regex
|
|
9
|
+
# in the hot loop. Auto-detects delimiter, quoting, escaping, BOM,
|
|
10
|
+
# and line endings. Supports excel mode, relax mode, headers, comments,
|
|
11
|
+
# streaming via row callback, and reusable writer instances.
|
|
12
|
+
# ==============================================================================
|
|
13
|
+
|
|
14
|
+
# ==[ Constants ]==
|
|
15
|
+
|
|
16
|
+
DELIMITERS =! [',', '\t', '|', ';']
|
|
17
|
+
CRLF =! '\r\n'
|
|
18
|
+
CR =! 13 # \r
|
|
19
|
+
LF =! 10 # \n
|
|
20
|
+
EQ =! 61 # =
|
|
21
|
+
|
|
22
|
+
# ==============================================================================
|
|
23
|
+
# Probe — auto-detect CSV dialect from the first few KB
|
|
24
|
+
# ==============================================================================
|
|
25
|
+
|
|
26
|
+
def probe(str, opts = {})
|
|
27
|
+
|
|
28
|
+
# strip BOM
|
|
29
|
+
if str.charCodeAt(0) is 0xFEFF
|
|
30
|
+
str = str.slice(1)
|
|
31
|
+
|
|
32
|
+
# detect sep= header (Excel convention)
|
|
33
|
+
if str[0..3] is "sep="
|
|
34
|
+
end = str.indexOf('\n')
|
|
35
|
+
end = str.indexOf('\r') if end is -1
|
|
36
|
+
stop = (end > 0 and str.charCodeAt(end - 1) is CR) ? end - 1 : end
|
|
37
|
+
sep = str.slice(4, stop)
|
|
38
|
+
str = str.slice(end + 1) if end >= 0
|
|
39
|
+
|
|
40
|
+
# sample first ~8KB for sniffing
|
|
41
|
+
sample = str.slice(0, 8192)
|
|
42
|
+
|
|
43
|
+
# detect line ending style
|
|
44
|
+
cr = sample.indexOf('\r')
|
|
45
|
+
lf = sample.indexOf('\n')
|
|
46
|
+
if cr >= 0 and lf is cr + 1
|
|
47
|
+
row = CRLF
|
|
48
|
+
else if lf >= 0
|
|
49
|
+
row = '\n'
|
|
50
|
+
else if cr >= 0
|
|
51
|
+
row = '\r'
|
|
52
|
+
else
|
|
53
|
+
row = '\n'
|
|
54
|
+
|
|
55
|
+
# detect delimiter from first line
|
|
56
|
+
lineEnd = sample.indexOf(row is CRLF ? '\r' : row)
|
|
57
|
+
lineEnd = sample.length if lineEnd is -1
|
|
58
|
+
firstLine = sample.slice(0, lineEnd)
|
|
59
|
+
unless opts.sep
|
|
60
|
+
best = null
|
|
61
|
+
bestCount = 0
|
|
62
|
+
for d in DELIMITERS
|
|
63
|
+
n = 0
|
|
64
|
+
i = -1
|
|
65
|
+
n++ while (i = firstLine.indexOf(d, i + 1)) isnt -1
|
|
66
|
+
if n > bestCount
|
|
67
|
+
best = d
|
|
68
|
+
bestCount = n
|
|
69
|
+
sep ?= best ? ','
|
|
70
|
+
|
|
71
|
+
# detect quoting
|
|
72
|
+
quote = opts.quote ? '"'
|
|
73
|
+
hasQuotes = sample.indexOf(quote) >= 0
|
|
74
|
+
|
|
75
|
+
# detect escape style: backslash vs doubled quote
|
|
76
|
+
escape = opts.escape
|
|
77
|
+
unless escape
|
|
78
|
+
if hasQuotes
|
|
79
|
+
escape = sample.indexOf("\\#{quote}") >= 0 ? '\\' : quote
|
|
80
|
+
else
|
|
81
|
+
escape = quote
|
|
82
|
+
|
|
83
|
+
# merge with user options (user wins)
|
|
84
|
+
{
|
|
85
|
+
str
|
|
86
|
+
sep: opts.sep ? sep
|
|
87
|
+
quote: quote
|
|
88
|
+
escape: escape
|
|
89
|
+
row: opts.row ? row
|
|
90
|
+
hasQuotes: hasQuotes
|
|
91
|
+
excel: opts.excel ? false
|
|
92
|
+
relax: opts.relax ? false
|
|
93
|
+
strip: opts.strip ? false
|
|
94
|
+
headers: opts.headers ? false
|
|
95
|
+
comments: opts.comments ? null
|
|
96
|
+
skipBlanks: opts.skipBlanks ? true
|
|
97
|
+
each: opts.each ?? null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
# ==============================================================================
|
|
101
|
+
# Helpers — emit rows with headers/callback support
|
|
102
|
+
# ==============================================================================
|
|
103
|
+
|
|
104
|
+
def makeEmitter(cfg)
|
|
105
|
+
{headers, strip, each} = cfg
|
|
106
|
+
ctx = {keys: null, rows: (each ? null : []), count: 0}
|
|
107
|
+
|
|
108
|
+
emit = (row) ->
|
|
109
|
+
row = row.map((c) -> c.trim()) if strip
|
|
110
|
+
|
|
111
|
+
# first row becomes keys in headers mode
|
|
112
|
+
if headers and not ctx.keys
|
|
113
|
+
ctx.keys = row
|
|
114
|
+
return true
|
|
115
|
+
|
|
116
|
+
# zip with keys for object output
|
|
117
|
+
if ctx.keys
|
|
118
|
+
obj = {}
|
|
119
|
+
for key, i in ctx.keys
|
|
120
|
+
obj[key] = row[i] ? ''
|
|
121
|
+
if each
|
|
122
|
+
ctx.count++
|
|
123
|
+
return each(obj, ctx.count - 1) isnt false
|
|
124
|
+
ctx.rows.push obj
|
|
125
|
+
return true
|
|
126
|
+
|
|
127
|
+
# plain array output
|
|
128
|
+
if each
|
|
129
|
+
ctx.count++
|
|
130
|
+
return each(row, ctx.count - 1) isnt false
|
|
131
|
+
ctx.rows.push row
|
|
132
|
+
true
|
|
133
|
+
|
|
134
|
+
result = -> each ? ctx.count : ctx.rows
|
|
135
|
+
|
|
136
|
+
{emit, result}
|
|
137
|
+
|
|
138
|
+
# ==============================================================================
|
|
139
|
+
# Helper — advance past \r\n or single \r or \n
|
|
140
|
+
# ==============================================================================
|
|
141
|
+
|
|
142
|
+
def crlfLen(str, pos)
|
|
143
|
+
if str.charCodeAt(pos) is CR and str.charCodeAt(pos + 1) is LF then 2 else 1
|
|
144
|
+
|
|
145
|
+
# ==============================================================================
|
|
146
|
+
# Reader — Fast path (no quotes detected)
|
|
147
|
+
# ==============================================================================
|
|
148
|
+
|
|
149
|
+
def readFast(str, cfg)
|
|
150
|
+
{sep, row: rowDelim, comments, skipBlanks} = cfg
|
|
151
|
+
{emit, result} = makeEmitter(cfg)
|
|
152
|
+
|
|
153
|
+
sepLen = sep.length
|
|
154
|
+
rowLen = rowDelim.length
|
|
155
|
+
len = str.length
|
|
156
|
+
pos = 0
|
|
157
|
+
|
|
158
|
+
while pos < len
|
|
159
|
+
# find end of current line
|
|
160
|
+
rowEnd = str.indexOf(rowDelim, pos)
|
|
161
|
+
rowEnd = len if rowEnd is -1
|
|
162
|
+
|
|
163
|
+
# skip empty lines
|
|
164
|
+
if pos is rowEnd
|
|
165
|
+
pos = rowEnd + rowLen
|
|
166
|
+
continue if skipBlanks
|
|
167
|
+
|
|
168
|
+
# skip comment lines
|
|
169
|
+
if comments and str[pos] is comments
|
|
170
|
+
pos = rowEnd + rowLen
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
# extract fields with indexOf ratchet for separator
|
|
174
|
+
row = []
|
|
175
|
+
p = pos
|
|
176
|
+
loop
|
|
177
|
+
s = str.indexOf(sep, p)
|
|
178
|
+
if s >= 0 and s < rowEnd
|
|
179
|
+
row.push str.slice(p, s)
|
|
180
|
+
p = s + sepLen
|
|
181
|
+
if p >= rowEnd
|
|
182
|
+
row.push '' # trailing separator -> empty final field
|
|
183
|
+
break
|
|
184
|
+
else
|
|
185
|
+
row.push str.slice(p, rowEnd)
|
|
186
|
+
break
|
|
187
|
+
|
|
188
|
+
pos = rowEnd + rowLen
|
|
189
|
+
|
|
190
|
+
# trim trailing \r for mixed line endings
|
|
191
|
+
last = row.length - 1
|
|
192
|
+
if last >= 0 and row[last].endsWith('\r')
|
|
193
|
+
row[last] = row[last].slice(0, -1)
|
|
194
|
+
|
|
195
|
+
break unless emit(row)
|
|
196
|
+
|
|
197
|
+
result()
|
|
198
|
+
|
|
199
|
+
# ==============================================================================
|
|
200
|
+
# Reader — Full path (quotes present)
|
|
201
|
+
# ==============================================================================
|
|
202
|
+
|
|
203
|
+
def readFull(str, cfg)
|
|
204
|
+
{sep, quote, escape, excel, relax} = cfg
|
|
205
|
+
{comments, skipBlanks} = cfg
|
|
206
|
+
{emit, result} = makeEmitter(cfg)
|
|
207
|
+
|
|
208
|
+
sepCode = sep.charCodeAt(0)
|
|
209
|
+
quoteCode = quote.charCodeAt(0)
|
|
210
|
+
sepLen = sep.length
|
|
211
|
+
escSame = escape is quote
|
|
212
|
+
escCode = escape.charCodeAt(0)
|
|
213
|
+
len = str.length
|
|
214
|
+
pos = 0
|
|
215
|
+
|
|
216
|
+
row = []
|
|
217
|
+
atLineStart = true
|
|
218
|
+
|
|
219
|
+
while pos < len
|
|
220
|
+
c = str.charCodeAt(pos)
|
|
221
|
+
|
|
222
|
+
# skip empty lines at line start
|
|
223
|
+
if atLineStart
|
|
224
|
+
if skipBlanks and (c is LF or c is CR)
|
|
225
|
+
pos += crlfLen(str, pos)
|
|
226
|
+
continue
|
|
227
|
+
if comments and str[pos] is comments
|
|
228
|
+
nl = str.indexOf('\n', pos)
|
|
229
|
+
if nl is -1
|
|
230
|
+
nl = str.indexOf('\r', pos)
|
|
231
|
+
pos = nl is -1 ? len : nl + 1
|
|
232
|
+
continue
|
|
233
|
+
atLineStart = false
|
|
234
|
+
|
|
235
|
+
# === quoted field ===
|
|
236
|
+
if c is quoteCode or (excel and c is EQ and str.charCodeAt(pos + 1) is quoteCode)
|
|
237
|
+
if excel and c is EQ
|
|
238
|
+
pos += 2 # skip ="
|
|
239
|
+
else
|
|
240
|
+
pos += 1 # skip opening quote
|
|
241
|
+
|
|
242
|
+
field = ''
|
|
243
|
+
loop
|
|
244
|
+
# indexOf to jump to next quote — bulk skip over content
|
|
245
|
+
q = str.indexOf(quote, pos)
|
|
246
|
+
|
|
247
|
+
unless q >= 0
|
|
248
|
+
# no closing quote found
|
|
249
|
+
if relax
|
|
250
|
+
field += str.slice(pos)
|
|
251
|
+
pos = len
|
|
252
|
+
break
|
|
253
|
+
throw new Error "CSV: unclosed quote at position #{pos}"
|
|
254
|
+
|
|
255
|
+
field += str.slice(pos, q)
|
|
256
|
+
pos = q + quote.length
|
|
257
|
+
|
|
258
|
+
# doubled-quote escape: "" -> "
|
|
259
|
+
if escSame
|
|
260
|
+
if pos < len and str.charCodeAt(pos) is quoteCode
|
|
261
|
+
field += quote
|
|
262
|
+
pos += quote.length
|
|
263
|
+
continue
|
|
264
|
+
else
|
|
265
|
+
# backslash escape: \" -> "
|
|
266
|
+
if q > 0 and str.charCodeAt(q - 1) is escCode
|
|
267
|
+
field = field.slice(0, -1) + quote
|
|
268
|
+
continue
|
|
269
|
+
|
|
270
|
+
# check what follows the closing quote
|
|
271
|
+
break if pos >= len # end of string
|
|
272
|
+
|
|
273
|
+
c2 = str.charCodeAt(pos)
|
|
274
|
+
break if c2 is sepCode or c2 is LF or c2 is CR # valid end-of-field
|
|
275
|
+
|
|
276
|
+
# unexpected character after closing quote
|
|
277
|
+
unless relax
|
|
278
|
+
throw new Error "CSV: unexpected character after quote at position #{pos}"
|
|
279
|
+
|
|
280
|
+
# relax mode: treat the quote as literal, keep scanning
|
|
281
|
+
field += quote
|
|
282
|
+
continue
|
|
283
|
+
|
|
284
|
+
# push field and consume trailing delimiter
|
|
285
|
+
row.push field
|
|
286
|
+
|
|
287
|
+
if pos < len
|
|
288
|
+
c2 = str.charCodeAt(pos)
|
|
289
|
+
if c2 is sepCode
|
|
290
|
+
pos += sepLen
|
|
291
|
+
else if c2 is LF or c2 is CR
|
|
292
|
+
pos += crlfLen(str, pos)
|
|
293
|
+
break unless emit(row)
|
|
294
|
+
row = []
|
|
295
|
+
atLineStart = true
|
|
296
|
+
|
|
297
|
+
# === newline (end of row) ===
|
|
298
|
+
else if c is LF or c is CR
|
|
299
|
+
pos += crlfLen(str, pos)
|
|
300
|
+
break unless emit(row)
|
|
301
|
+
row = []
|
|
302
|
+
atLineStart = true
|
|
303
|
+
|
|
304
|
+
# === separator (empty field) ===
|
|
305
|
+
else if c is sepCode
|
|
306
|
+
row.push ''
|
|
307
|
+
pos += sepLen
|
|
308
|
+
|
|
309
|
+
# === unquoted field ===
|
|
310
|
+
else
|
|
311
|
+
# indexOf ratchet: find nearest sep or newline
|
|
312
|
+
s = str.indexOf(sep, pos)
|
|
313
|
+
n = str.indexOf('\n', pos)
|
|
314
|
+
r = str.indexOf('\r', pos)
|
|
315
|
+
|
|
316
|
+
# nearest newline (\r or \n)
|
|
317
|
+
if r >= 0 and n >= 0
|
|
318
|
+
nl = Math.min(r, n)
|
|
319
|
+
else if r >= 0
|
|
320
|
+
nl = r
|
|
321
|
+
else
|
|
322
|
+
nl = n
|
|
323
|
+
|
|
324
|
+
# take the nearer boundary
|
|
325
|
+
if s >= 0 and (nl is -1 or s < nl)
|
|
326
|
+
row.push str.slice(pos, s)
|
|
327
|
+
pos = s + sepLen
|
|
328
|
+
else if nl >= 0
|
|
329
|
+
row.push str.slice(pos, nl)
|
|
330
|
+
pos = nl + crlfLen(str, nl)
|
|
331
|
+
break unless emit(row)
|
|
332
|
+
row = []
|
|
333
|
+
atLineStart = true
|
|
334
|
+
else
|
|
335
|
+
row.push str.slice(pos)
|
|
336
|
+
pos = len
|
|
337
|
+
|
|
338
|
+
# emit final row if pending
|
|
339
|
+
emit(row) if row.length > 0
|
|
340
|
+
|
|
341
|
+
result()
|
|
342
|
+
|
|
343
|
+
# ==============================================================================
|
|
344
|
+
# Writer — format data as CSV strings
|
|
345
|
+
# ==============================================================================
|
|
346
|
+
|
|
347
|
+
class Writer
|
|
348
|
+
constructor: (opts = {}) ->
|
|
349
|
+
@sep = opts.sep ? ','
|
|
350
|
+
@quote = opts.quote ? '"'
|
|
351
|
+
@escape = opts.escape ? @quote
|
|
352
|
+
@mode = opts.mode ? 'compact'
|
|
353
|
+
@excel = opts.excel ? false
|
|
354
|
+
@drop = opts.drop ? false
|
|
355
|
+
@rowsep = opts.rowsep ? '\n'
|
|
356
|
+
|
|
357
|
+
# pre-compute escaped quote
|
|
358
|
+
@esc = @escape + @quote
|
|
359
|
+
@leadZero = /^0\d+$/
|
|
360
|
+
|
|
361
|
+
# check if a cell value needs quoting
|
|
362
|
+
needsQuote: (cell) ->
|
|
363
|
+
cell.indexOf(@sep) >= 0 or
|
|
364
|
+
cell.indexOf('\n') >= 0 or
|
|
365
|
+
cell.indexOf('\r') >= 0 or
|
|
366
|
+
cell.indexOf(@quote) >= 0
|
|
367
|
+
|
|
368
|
+
# format a single row as a CSV line (no trailing row separator)
|
|
369
|
+
row: (data) ->
|
|
370
|
+
cells = (String(v ? '') for v in data)
|
|
371
|
+
|
|
372
|
+
# drop trailing empty columns
|
|
373
|
+
if @drop
|
|
374
|
+
cells.pop() while cells.length > 0 and cells[cells.length - 1] is ''
|
|
375
|
+
|
|
376
|
+
q = @quote
|
|
377
|
+
esc = @esc
|
|
378
|
+
|
|
379
|
+
formatted = switch @mode
|
|
380
|
+
when 'compact'
|
|
381
|
+
for cell in cells
|
|
382
|
+
if @excel and @leadZero.test(cell)
|
|
383
|
+
"=#{q}#{cell}#{q}"
|
|
384
|
+
else if @needsQuote(cell)
|
|
385
|
+
"#{q}#{cell.replaceAll(q, esc)}#{q}"
|
|
386
|
+
else
|
|
387
|
+
cell
|
|
388
|
+
when 'full'
|
|
389
|
+
for cell in cells
|
|
390
|
+
if @excel and @leadZero.test(cell)
|
|
391
|
+
"=#{q}#{cell}#{q}"
|
|
392
|
+
else
|
|
393
|
+
"#{q}#{cell.replaceAll(q, esc)}#{q}"
|
|
394
|
+
else
|
|
395
|
+
cells
|
|
396
|
+
|
|
397
|
+
formatted.join @sep
|
|
398
|
+
|
|
399
|
+
# format multiple rows as a complete CSV string
|
|
400
|
+
rows: (data) ->
|
|
401
|
+
return '' unless data?.length
|
|
402
|
+
((@row(r) for r in data).join(@rowsep)) + @rowsep
|
|
403
|
+
|
|
404
|
+
# ==============================================================================
|
|
405
|
+
# Public API
|
|
406
|
+
# ==============================================================================
|
|
407
|
+
|
|
408
|
+
export CSV =
|
|
409
|
+
# parse a CSV string into rows (arrays or objects)
|
|
410
|
+
read: (str, opts = {}) ->
|
|
411
|
+
return [] unless str?.length
|
|
412
|
+
cfg = probe(str, opts)
|
|
413
|
+
if cfg.hasQuotes
|
|
414
|
+
readFull(cfg.str, cfg)
|
|
415
|
+
else
|
|
416
|
+
readFast(cfg.str, cfg)
|
|
417
|
+
|
|
418
|
+
# format row arrays into a CSV string
|
|
419
|
+
write: (rows, opts = {}) ->
|
|
420
|
+
new Writer(opts).rows(rows)
|
|
421
|
+
|
|
422
|
+
# read and parse a CSV file (async — uses Bun.file)
|
|
423
|
+
load: (path, opts = {}) ->
|
|
424
|
+
str = Bun.file(path).text!
|
|
425
|
+
CSV.read str, opts
|
|
426
|
+
|
|
427
|
+
# write rows to a CSV file (async — uses Bun.write)
|
|
428
|
+
save: (path, rows, opts = {}) ->
|
|
429
|
+
Bun.write! path, CSV.write(rows, opts)
|
|
430
|
+
|
|
431
|
+
# create a reusable Writer instance
|
|
432
|
+
writer: (opts = {}) ->
|
|
433
|
+
new Writer(opts)
|
|
434
|
+
|
|
435
|
+
# format a single row (convenience — creates a one-shot Writer)
|
|
436
|
+
formatRow: (row, opts = {}) ->
|
|
437
|
+
new Writer(opts).row(row)
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rip-lang/csv",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Fast, flexible CSV parser and writer for Rip — indexOf ratchet engine, auto-detection, zero dependencies",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "csv.rip",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./csv.rip"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "rip test/basic.rip"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"csv",
|
|
15
|
+
"parser",
|
|
16
|
+
"writer",
|
|
17
|
+
"fast",
|
|
18
|
+
"indexOf",
|
|
19
|
+
"bun",
|
|
20
|
+
"rip"
|
|
21
|
+
],
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/shreeve/rip-lang.git",
|
|
25
|
+
"directory": "packages/csv"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/shreeve/rip-lang/tree/main/packages/csv#readme",
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/shreeve/rip-lang/issues"
|
|
30
|
+
},
|
|
31
|
+
"author": "Steve Shreeve <steve.shreeve@gmail.com>",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"rip-lang": "^2.9.0"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"csv.rip",
|
|
38
|
+
"README.md"
|
|
39
|
+
]
|
|
40
|
+
}
|