@jarenjs/josl 0.34.0
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/FORMAT.md +235 -0
- package/README.md +494 -0
- package/dist/types/cst.d.ts +78 -0
- package/dist/types/csv-machine.d.ts +104 -0
- package/dist/types/csv-stream.d.ts +102 -0
- package/dist/types/csv.d.ts +141 -0
- package/dist/types/errors.d.ts +79 -0
- package/dist/types/gbnf.d.ts +20 -0
- package/dist/types/index.d.ts +12 -0
- package/dist/types/jsonx-scalar.d.ts +92 -0
- package/dist/types/jsonx-stream.d.ts +163 -0
- package/dist/types/jsonx.d.ts +31 -0
- package/dist/types/machine.d.ts +97 -0
- package/dist/types/parse.d.ts +24 -0
- package/dist/types/stream.d.ts +32 -0
- package/dist/types/stringify.d.ts +63 -0
- package/dist/types/util.d.ts +56 -0
- package/dist/types/values.d.ts +60 -0
- package/dist/types/write.d.ts +92 -0
- package/package.json +104 -0
- package/schemas/jaren-josl-data.schema.json +21 -0
- package/src/cst.js +256 -0
- package/src/csv-machine.js +908 -0
- package/src/csv-stream.js +196 -0
- package/src/csv.js +363 -0
- package/src/errors.js +103 -0
- package/src/gbnf.js +179 -0
- package/src/index.js +50 -0
- package/src/jsonx-scalar.js +326 -0
- package/src/jsonx-stream.js +806 -0
- package/src/jsonx.js +342 -0
- package/src/machine.js +1252 -0
- package/src/parse.js +37 -0
- package/src/stream.js +57 -0
- package/src/stringify.js +341 -0
- package/src/util.js +96 -0
- package/src/values.js +104 -0
- package/src/write.js +226 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
//#region CSV streaming reader and writer
|
|
2
|
+
// Chunk-feedable reader for incremental input — a network response, a
|
|
3
|
+
// file read in pieces, an LLM emitting a table token by token. Rows are
|
|
4
|
+
// complete the moment their record terminates, so a consumer can work on
|
|
5
|
+
// row 1 while row 100000 is still on the wire.
|
|
6
|
+
//
|
|
7
|
+
// The reader drives the same machine as `parseCsv`, so a document read in
|
|
8
|
+
// chunks and the same document read whole produce identical rows; the
|
|
9
|
+
// test suite asserts exactly that at every chunk size.
|
|
10
|
+
|
|
11
|
+
import { CsvMachine } from './csv-machine.js';
|
|
12
|
+
import { stringifyCsvChunks, formatCsvValue } from './csv.js';
|
|
13
|
+
|
|
14
|
+
//#region reading
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create an incremental CSV reader.
|
|
18
|
+
*
|
|
19
|
+
* Events, in document order:
|
|
20
|
+
* {type:'header', fields, line} - column names resolved
|
|
21
|
+
* {type:'row', index, values, record, line} - a record completed
|
|
22
|
+
* {type:'repair', code, message, line, column} - damage healed
|
|
23
|
+
*
|
|
24
|
+
* @param {object} [options] - Reader options; see `parseCsv`
|
|
25
|
+
* @returns {{feed(chunk: string): void, end(): Array, rows(): Array,
|
|
26
|
+
* fields(): string[]|null, repairs(): object[]}} The reader: `feed`
|
|
27
|
+
* accepts chunks that may split any field, `end` flushes the pending
|
|
28
|
+
* record and returns every row, and the rest peek at partial results.
|
|
29
|
+
* @example
|
|
30
|
+
* const reader = createCsvStreamReader({ headers: true, onEvent: console.log });
|
|
31
|
+
* reader.feed('a,b\n1,');
|
|
32
|
+
* reader.feed('2\n');
|
|
33
|
+
* reader.end(); // [{ a: '1', b: '2' }]
|
|
34
|
+
*/
|
|
35
|
+
export function createCsvStreamReader(options = undefined) {
|
|
36
|
+
const machine = new CsvMachine(options);
|
|
37
|
+
return {
|
|
38
|
+
feed(chunk) {
|
|
39
|
+
machine.feed(chunk);
|
|
40
|
+
},
|
|
41
|
+
end() {
|
|
42
|
+
return machine.end();
|
|
43
|
+
},
|
|
44
|
+
rows() {
|
|
45
|
+
return machine.rows();
|
|
46
|
+
},
|
|
47
|
+
fields() {
|
|
48
|
+
return machine.fields();
|
|
49
|
+
},
|
|
50
|
+
repairs() {
|
|
51
|
+
return machine.repairs();
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read an async iterable of string chunks (a fetch body, a file stream,
|
|
58
|
+
* an LLM response) into records.
|
|
59
|
+
* @param {AsyncIterable<string>|Iterable<string>} chunks - Source chunks
|
|
60
|
+
* @param {object} [options] - Reader options; see `parseCsv`
|
|
61
|
+
* @returns {Promise<Array>} Every record
|
|
62
|
+
*/
|
|
63
|
+
export async function parseCsvStream(chunks, options = undefined) {
|
|
64
|
+
const machine = new CsvMachine(options);
|
|
65
|
+
for await (const chunk of chunks)
|
|
66
|
+
machine.feed(chunk);
|
|
67
|
+
return machine.end();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Yield records as they complete, without ever holding the whole table.
|
|
72
|
+
*
|
|
73
|
+
* This is the reason to stream: `parseCsvStream` still accumulates every
|
|
74
|
+
* row, so a file larger than memory needs a reader that hands each record
|
|
75
|
+
* over and forgets it. Rows are dropped from the machine as they are
|
|
76
|
+
* yielded, so memory stays flat in the number of records.
|
|
77
|
+
* @param {AsyncIterable<string>|Iterable<string>} chunks - Source chunks
|
|
78
|
+
* @param {object} [options] - Reader options; see `parseCsv`
|
|
79
|
+
* @yields {Array|object} One record at a time
|
|
80
|
+
* @example
|
|
81
|
+
* for await (const row of iterateCsvStream(response.body, { headers: true }))
|
|
82
|
+
* await save(row);
|
|
83
|
+
*/
|
|
84
|
+
export async function* iterateCsvStream(chunks, options = undefined) {
|
|
85
|
+
const machine = new CsvMachine(options);
|
|
86
|
+
const pending = machine.rows();
|
|
87
|
+
for await (const chunk of chunks) {
|
|
88
|
+
machine.feed(chunk);
|
|
89
|
+
if (pending.length !== 0) {
|
|
90
|
+
// hand over the completed rows and drop them, so the machine never
|
|
91
|
+
// accumulates the document it is streaming
|
|
92
|
+
const batch = pending.splice(0, pending.length);
|
|
93
|
+
for (const row of batch)
|
|
94
|
+
yield row;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
machine.end();
|
|
98
|
+
for (const row of pending.splice(0, pending.length))
|
|
99
|
+
yield row;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
//#endregion
|
|
103
|
+
|
|
104
|
+
//#region writing
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* An incremental CSV writer: rows in, chunks out.
|
|
108
|
+
*/
|
|
109
|
+
export class CsvStreamWriter {
|
|
110
|
+
/**
|
|
111
|
+
* @param {object} [options] - Writer options; see `stringifyCsv`
|
|
112
|
+
*/
|
|
113
|
+
constructor(options = {}) {
|
|
114
|
+
this.options = options;
|
|
115
|
+
this.chunks = [];
|
|
116
|
+
this.onChunk = options.onChunk ?? null;
|
|
117
|
+
this.fields = options.fields ?? null;
|
|
118
|
+
this.wroteHeader = options.header === false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
#emit(text) {
|
|
122
|
+
if (this.onChunk !== null)
|
|
123
|
+
this.onChunk(text);
|
|
124
|
+
else
|
|
125
|
+
this.chunks.push(text);
|
|
126
|
+
return this;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Write one record.
|
|
131
|
+
* @param {Array|object} row - An array, or an object keyed by column
|
|
132
|
+
* @returns {this} The writer, for chaining
|
|
133
|
+
*/
|
|
134
|
+
write(row) {
|
|
135
|
+
if (!Array.isArray(row) && this.fields === null)
|
|
136
|
+
this.fields = Object.keys(row);
|
|
137
|
+
for (const chunk of stringifyCsvChunks([row], {
|
|
138
|
+
...this.options,
|
|
139
|
+
fields: this.fields,
|
|
140
|
+
header: !this.wroteHeader && this.options.header !== false,
|
|
141
|
+
})) {
|
|
142
|
+
this.#emit(chunk);
|
|
143
|
+
}
|
|
144
|
+
if (!Array.isArray(row))
|
|
145
|
+
this.wroteHeader = true;
|
|
146
|
+
return this;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Write many records.
|
|
151
|
+
* @param {Iterable<Array|object>} rows - Records
|
|
152
|
+
* @returns {this} The writer, for chaining
|
|
153
|
+
*/
|
|
154
|
+
writeAll(rows) {
|
|
155
|
+
for (const row of rows)
|
|
156
|
+
this.write(row);
|
|
157
|
+
return this;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Concatenate the buffered chunks; empty when an `onChunk` sink is set.
|
|
162
|
+
* @returns {string} The document so far
|
|
163
|
+
*/
|
|
164
|
+
toString() {
|
|
165
|
+
return this.chunks.join('');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Finish writing.
|
|
170
|
+
* @returns {string} The complete document, or `''` with an `onChunk` sink
|
|
171
|
+
*/
|
|
172
|
+
end() {
|
|
173
|
+
return this.toString();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Create an incremental CSV writer.
|
|
179
|
+
* @param {object} [options] - Writer options; see `stringifyCsv`
|
|
180
|
+
* @param {(chunk: string) => void} [options.onChunk] - Chunk sink; without
|
|
181
|
+
* one, chunks buffer until `end()`
|
|
182
|
+
* @returns {CsvStreamWriter} The writer
|
|
183
|
+
* @example
|
|
184
|
+
* const w = createCsvStreamWriter();
|
|
185
|
+
* w.write({ a: 1, b: 2 }).write({ a: 3, b: 4 });
|
|
186
|
+
* w.end(); // 'a,b\r\n1,2\r\n3,4\r\n'
|
|
187
|
+
*/
|
|
188
|
+
export function createCsvStreamWriter(options = undefined) {
|
|
189
|
+
return new CsvStreamWriter(options ?? {});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
//#endregion
|
|
193
|
+
|
|
194
|
+
export { stringifyCsvChunks, formatCsvValue };
|
|
195
|
+
|
|
196
|
+
//#endregion
|
package/src/csv.js
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
//#region CSV whole-document API
|
|
2
|
+
// Reading and writing a complete CSV document. The reader is the same
|
|
3
|
+
// machine the streaming reader drives (csv-machine.js), so a document
|
|
4
|
+
// parses identically whether it arrives at once or in chunks.
|
|
5
|
+
|
|
6
|
+
import { CsvMachine, CSV_CODES, coerceCsvValue } from './csv-machine.js';
|
|
7
|
+
import { CsvSyntaxError } from './errors.js';
|
|
8
|
+
|
|
9
|
+
//#region reading
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Parse a complete CSV document.
|
|
13
|
+
*
|
|
14
|
+
* Default is **strict**: anything RFC 4180 does not allow throws a
|
|
15
|
+
* `CsvSyntaxError` carrying a `CSV1xxx` code, a line and a column.
|
|
16
|
+
* `repair: true` instead takes the reading that loses the least data,
|
|
17
|
+
* records it, and carries on — see `parseCsvDocument` for the log.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} text - CSV source text
|
|
20
|
+
* @param {object} [options] - Reader options
|
|
21
|
+
* @param {string} [options.delimiter=','] - Field separator, one character
|
|
22
|
+
* @param {string|null} [options.quote='"'] - Quote character; `null` disables quoting
|
|
23
|
+
* @param {string|null} [options.comment] - Lines starting with it are skipped
|
|
24
|
+
* @param {boolean|string[]} [options.headers=false] - `true` reads the first
|
|
25
|
+
* record as column names, an array supplies them; either way records become
|
|
26
|
+
* objects. `false` yields arrays, which allocate less.
|
|
27
|
+
* @param {boolean} [options.repair=false] - Heal damage instead of throwing
|
|
28
|
+
* @param {boolean} [options.trim=false] - Trim spaces/tabs around unquoted fields
|
|
29
|
+
* @param {boolean} [options.typed=false] - Coerce numbers, bigints, booleans,
|
|
30
|
+
* `null` and ISO dates; quoted cells always stay strings
|
|
31
|
+
* @param {boolean} [options.emptyAsNull=false] - Empty cells become `null`
|
|
32
|
+
* @param {boolean} [options.skipEmptyLines] - Drop blank lines (default: on
|
|
33
|
+
* in repair mode, off otherwise)
|
|
34
|
+
* @param {(event: object) => void} [options.onEvent] - Document-order event sink
|
|
35
|
+
* @param {(repair: object) => void} [options.onRepair] - Repair sink
|
|
36
|
+
* @returns {Array<string[]|object>} The records
|
|
37
|
+
* @throws {CsvSyntaxError} On malformed input, unless `repair` is set
|
|
38
|
+
* @example
|
|
39
|
+
* parseCsv('a,b\n1,2'); // [['a','b'], ['1','2']]
|
|
40
|
+
* parseCsv('a,b\n1,2', { headers: true }); // [{ a: '1', b: '2' }]
|
|
41
|
+
*/
|
|
42
|
+
export function parseCsv(text, options = undefined) {
|
|
43
|
+
return new CsvMachine(options).parseAll(text);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse a complete CSV document and report everything that was learned
|
|
48
|
+
* about it: the resolved dialect, the column names, the records, and the
|
|
49
|
+
* repair log. This is the form to use when the input is untrusted — it is
|
|
50
|
+
* the only way to see *what* had to be healed.
|
|
51
|
+
* @param {string} text - CSV source text
|
|
52
|
+
* @param {object} [options] - Reader options; see `parseCsv`. `delimiter:
|
|
53
|
+
* 'auto'` sniffs the dialect from the text first.
|
|
54
|
+
* @returns {{dialect: object, fields: string[]|null, rows: Array, repairs: object[]}}
|
|
55
|
+
* The document
|
|
56
|
+
* @example
|
|
57
|
+
* const doc = parseCsvDocument('a;b\n1;2', { delimiter: 'auto', headers: true });
|
|
58
|
+
* doc.dialect.delimiter; // ';'
|
|
59
|
+
* doc.rows; // [{ a: '1', b: '2' }]
|
|
60
|
+
*/
|
|
61
|
+
export function parseCsvDocument(text, options = undefined) {
|
|
62
|
+
let opts = options ?? {};
|
|
63
|
+
let dialect;
|
|
64
|
+
if (opts.delimiter === 'auto') {
|
|
65
|
+
dialect = sniffCsvDialect(text, opts);
|
|
66
|
+
opts = { ...opts, delimiter: dialect.delimiter };
|
|
67
|
+
if (opts.headers === 'auto')
|
|
68
|
+
opts.headers = dialect.headers;
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
if (opts.headers === 'auto')
|
|
72
|
+
opts = { ...opts, headers: sniffCsvDialect(text, opts).headers };
|
|
73
|
+
dialect = {
|
|
74
|
+
delimiter: opts.delimiter ?? ',',
|
|
75
|
+
quote: opts.quote === undefined ? '"' : opts.quote,
|
|
76
|
+
headers: opts.headers === true || Array.isArray(opts.headers),
|
|
77
|
+
confidence: 1,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const machine = new CsvMachine(opts);
|
|
81
|
+
const rows = machine.parseAll(text);
|
|
82
|
+
return { dialect, fields: machine.fields(), rows, repairs: machine.repairs() };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
//#endregion
|
|
86
|
+
|
|
87
|
+
//#region dialect sniffing
|
|
88
|
+
|
|
89
|
+
const CANDIDATES = [',', ';', '\t', '|', ':'];
|
|
90
|
+
const SNIFF_BYTES = 65536;
|
|
91
|
+
const SNIFF_RECORDS = 50;
|
|
92
|
+
|
|
93
|
+
// Read up to `SNIFF_RECORDS` records with one candidate delimiter, in
|
|
94
|
+
// repair mode so damage never aborts the probe, and report how consistent
|
|
95
|
+
// the record widths are. A real delimiter produces the same width on
|
|
96
|
+
// nearly every line; a wrong one produces width 1 or noise.
|
|
97
|
+
function probeDelimiter(sample, delimiter, options) {
|
|
98
|
+
const widths = [];
|
|
99
|
+
const machine = new CsvMachine({
|
|
100
|
+
...options,
|
|
101
|
+
delimiter,
|
|
102
|
+
headers: false,
|
|
103
|
+
typed: false,
|
|
104
|
+
repair: true,
|
|
105
|
+
onEvent: null,
|
|
106
|
+
onRepair: null,
|
|
107
|
+
});
|
|
108
|
+
let rows;
|
|
109
|
+
try {
|
|
110
|
+
rows = machine.parseAll(sample);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return { score: 0, width: 0 };
|
|
114
|
+
}
|
|
115
|
+
const limit = rows.length < SNIFF_RECORDS ? rows.length : SNIFF_RECORDS;
|
|
116
|
+
for (let i = 0; i < limit; i++)
|
|
117
|
+
widths.push(rows[i].length);
|
|
118
|
+
if (widths.length === 0)
|
|
119
|
+
return { score: 0, width: 0 };
|
|
120
|
+
|
|
121
|
+
const counts = new Map();
|
|
122
|
+
for (const w of widths)
|
|
123
|
+
counts.set(w, (counts.get(w) ?? 0) + 1);
|
|
124
|
+
let modal = 0;
|
|
125
|
+
let best = 0;
|
|
126
|
+
for (const [w, n] of counts) {
|
|
127
|
+
if (n > best || (n === best && w > modal)) {
|
|
128
|
+
best = n;
|
|
129
|
+
modal = w;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// A single column means the delimiter never appeared: no evidence at all,
|
|
133
|
+
// not perfect evidence.
|
|
134
|
+
if (modal < 2)
|
|
135
|
+
return { score: 0, width: modal };
|
|
136
|
+
return { score: best / widths.length, width: modal, rows };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Guess a CSV document's dialect from its text.
|
|
141
|
+
*
|
|
142
|
+
* The delimiter is chosen by consistency: each candidate is parsed and
|
|
143
|
+
* scored by how often it produces the same field count, because a
|
|
144
|
+
* separator that is really a separator divides every record the same way
|
|
145
|
+
* and one that is not divides them arbitrarily. Ties go to the delimiter
|
|
146
|
+
* yielding more columns, since a spurious separator can only ever split
|
|
147
|
+
* fewer.
|
|
148
|
+
*
|
|
149
|
+
* Header detection asks whether the first record looks unlike the rest:
|
|
150
|
+
* if every cell in it is a non-numeric string while some column below is
|
|
151
|
+
* consistently numeric or date-like, that first record is naming columns
|
|
152
|
+
* rather than carrying data.
|
|
153
|
+
*
|
|
154
|
+
* @param {string} text - CSV source text (a prefix is enough)
|
|
155
|
+
* @param {object} [options] - Reader options that affect parsing (`quote`, `comment`)
|
|
156
|
+
* @returns {{delimiter: string, quote: string|null, headers: boolean, width: number, confidence: number}}
|
|
157
|
+
* The guess, with `confidence` in 0..1
|
|
158
|
+
* @example
|
|
159
|
+
* sniffCsvDialect('a;b;c\n1;2;3'); // { delimiter: ';', headers: true, ... }
|
|
160
|
+
*/
|
|
161
|
+
export function sniffCsvDialect(text, options = undefined) {
|
|
162
|
+
const sample = text.length > SNIFF_BYTES ? text.slice(0, text.lastIndexOf('\n', SNIFF_BYTES) + 1 || SNIFF_BYTES) : text;
|
|
163
|
+
const opts = options ?? {};
|
|
164
|
+
let bestDelim = opts.delimiter && opts.delimiter !== 'auto' ? opts.delimiter : ',';
|
|
165
|
+
let bestScore = -1;
|
|
166
|
+
let bestWidth = 0;
|
|
167
|
+
let bestRows = null;
|
|
168
|
+
|
|
169
|
+
for (const candidate of CANDIDATES) {
|
|
170
|
+
const probe = probeDelimiter(sample, candidate, opts);
|
|
171
|
+
if (probe.score > bestScore || (probe.score === bestScore && probe.width > bestWidth)) {
|
|
172
|
+
bestScore = probe.score;
|
|
173
|
+
bestWidth = probe.width;
|
|
174
|
+
bestDelim = candidate;
|
|
175
|
+
bestRows = probe.rows ?? null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
delimiter: bestDelim,
|
|
181
|
+
quote: opts.quote === undefined ? '"' : opts.quote,
|
|
182
|
+
headers: bestRows === null ? false : looksLikeHeader(bestRows),
|
|
183
|
+
width: bestWidth,
|
|
184
|
+
confidence: bestScore < 0 ? 0 : bestScore,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// The first record names columns when it is all non-empty text and at
|
|
189
|
+
// least one column below it is consistently non-text. A table that is
|
|
190
|
+
// strings all the way down gives no evidence either way, so it is not a
|
|
191
|
+
// header — inventing one would silently eat a data row.
|
|
192
|
+
function looksLikeHeader(rows) {
|
|
193
|
+
if (rows.length < 2)
|
|
194
|
+
return false;
|
|
195
|
+
const head = rows[0];
|
|
196
|
+
for (const cell of head) {
|
|
197
|
+
if (cell === '' || cell === null || typeof coerceCsvValue(String(cell)) !== 'string')
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
const limit = rows.length < SNIFF_RECORDS ? rows.length : SNIFF_RECORDS;
|
|
201
|
+
for (let col = 0; col < head.length; col++) {
|
|
202
|
+
let typed = 0;
|
|
203
|
+
let seen = 0;
|
|
204
|
+
for (let r = 1; r < limit; r++) {
|
|
205
|
+
const cell = rows[r][col];
|
|
206
|
+
if (cell === undefined || cell === '' || cell === null)
|
|
207
|
+
continue;
|
|
208
|
+
seen++;
|
|
209
|
+
if (typeof coerceCsvValue(String(cell)) !== 'string')
|
|
210
|
+
typed++;
|
|
211
|
+
}
|
|
212
|
+
if (seen > 0 && typed === seen)
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
//#endregion
|
|
219
|
+
|
|
220
|
+
//#region writing
|
|
221
|
+
|
|
222
|
+
const RE_UNSAFE_CACHE = new Map();
|
|
223
|
+
|
|
224
|
+
function needsQuoteTester(delimiter, quote) {
|
|
225
|
+
const key = delimiter + quote;
|
|
226
|
+
let test = RE_UNSAFE_CACHE.get(key);
|
|
227
|
+
if (test === undefined) {
|
|
228
|
+
const dc = delimiter.charCodeAt(0);
|
|
229
|
+
const qc = quote.length === 0 ? -1 : quote.charCodeAt(0);
|
|
230
|
+
test = (s) => {
|
|
231
|
+
for (let i = 0; i < s.length; i++) {
|
|
232
|
+
const c = s.charCodeAt(i);
|
|
233
|
+
if (c === dc || c === qc || c === 0x0A || c === 0x0D)
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
// leading or trailing whitespace only survives a round-trip when
|
|
237
|
+
// the field is quoted, because a lenient reader may trim it
|
|
238
|
+
if (s.length !== 0) {
|
|
239
|
+
const a = s.charCodeAt(0);
|
|
240
|
+
const b = s.charCodeAt(s.length - 1);
|
|
241
|
+
if (a === 0x20 || a === 0x09 || b === 0x20 || b === 0x09)
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
return false;
|
|
245
|
+
};
|
|
246
|
+
RE_UNSAFE_CACHE.set(key, test);
|
|
247
|
+
}
|
|
248
|
+
return test;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Render one value as CSV cell text. `null`/`undefined` become empty,
|
|
253
|
+
* bigints lose the `n` suffix JOSL uses (CSV has no type marks), and the
|
|
254
|
+
* JOSL date classes and `Date` render as ISO-8601.
|
|
255
|
+
* @param {*} value - The value
|
|
256
|
+
* @returns {string} Cell text, unquoted
|
|
257
|
+
*/
|
|
258
|
+
export function formatCsvValue(value) {
|
|
259
|
+
if (value === null || value === undefined)
|
|
260
|
+
return '';
|
|
261
|
+
switch (typeof value) {
|
|
262
|
+
case 'string': return value;
|
|
263
|
+
case 'number': return Number.isFinite(value) ? String(value) : '';
|
|
264
|
+
case 'bigint': return String(value);
|
|
265
|
+
case 'boolean': return value ? 'true' : 'false';
|
|
266
|
+
default: break;
|
|
267
|
+
}
|
|
268
|
+
if (value instanceof Date)
|
|
269
|
+
return Number.isNaN(value.getTime()) ? '' : value.toISOString();
|
|
270
|
+
// LocalDate / LocalTime / LocalDateTime, and anything else that knows
|
|
271
|
+
// how to render itself
|
|
272
|
+
return String(value);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Serialize records to a CSV document.
|
|
277
|
+
*
|
|
278
|
+
* A field is quoted only when it has to be — when it contains the
|
|
279
|
+
* delimiter, a quote, a newline, or edge whitespace a lenient reader
|
|
280
|
+
* might trim. Everything else is written bare, which keeps the output
|
|
281
|
+
* both smaller and diff-friendly.
|
|
282
|
+
*
|
|
283
|
+
* @param {Array<Array|object>} rows - Records: arrays, or objects keyed by column
|
|
284
|
+
* @param {object} [options] - Writer options
|
|
285
|
+
* @param {string} [options.delimiter=','] - Field separator
|
|
286
|
+
* @param {string} [options.quote='"'] - Quote character
|
|
287
|
+
* @param {string} [options.newline='\r\n'] - Record terminator; RFC 4180 §2.1
|
|
288
|
+
* specifies CRLF, which is also what spreadsheet software expects
|
|
289
|
+
* @param {string[]} [options.fields] - Column order; inferred from the first
|
|
290
|
+
* object record when omitted
|
|
291
|
+
* @param {boolean} [options.header=true] - Emit a header row for object records
|
|
292
|
+
* @returns {string} The CSV document
|
|
293
|
+
* @example
|
|
294
|
+
* stringifyCsv([{ a: 1, b: 'x,y' }]); // 'a,b\r\n1,"x,y"\r\n'
|
|
295
|
+
*/
|
|
296
|
+
export function stringifyCsv(rows, options = {}) {
|
|
297
|
+
let out = '';
|
|
298
|
+
for (const chunk of stringifyCsvChunks(rows, options))
|
|
299
|
+
out += chunk;
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Serialize records as an iterable of chunks, one record at a time, so a
|
|
305
|
+
* large table never exists as a single string.
|
|
306
|
+
* @param {Iterable<Array|object>} rows - Records
|
|
307
|
+
* @param {object} [options] - Writer options; see `stringifyCsv`
|
|
308
|
+
* @yields {string} One record (or the header) at a time
|
|
309
|
+
*/
|
|
310
|
+
export function* stringifyCsvChunks(rows, options = {}) {
|
|
311
|
+
const delimiter = options.delimiter ?? ',';
|
|
312
|
+
const quote = options.quote ?? '"';
|
|
313
|
+
const newline = options.newline ?? '\r\n';
|
|
314
|
+
const wantHeader = options.header !== false;
|
|
315
|
+
const unsafe = needsQuoteTester(delimiter, quote);
|
|
316
|
+
const escaped = quote + quote;
|
|
317
|
+
|
|
318
|
+
const cell = (value) => {
|
|
319
|
+
const s = formatCsvValue(value);
|
|
320
|
+
if (quote.length === 0 || !unsafe(s))
|
|
321
|
+
return s;
|
|
322
|
+
return quote + (s.includes(quote) ? s.replaceAll(quote, escaped) : s) + quote;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
let fields = options.fields ?? null;
|
|
326
|
+
let emittedHeader = false;
|
|
327
|
+
for (const row of rows) {
|
|
328
|
+
if (Array.isArray(row)) {
|
|
329
|
+
let line = '';
|
|
330
|
+
for (let i = 0; i < row.length; i++)
|
|
331
|
+
line += (i === 0 ? '' : delimiter) + cell(row[i]);
|
|
332
|
+
yield line + newline;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (fields === null)
|
|
336
|
+
fields = Object.keys(row);
|
|
337
|
+
if (wantHeader && !emittedHeader) {
|
|
338
|
+
emittedHeader = true;
|
|
339
|
+
let head = '';
|
|
340
|
+
for (let i = 0; i < fields.length; i++)
|
|
341
|
+
head += (i === 0 ? '' : delimiter) + cell(fields[i]);
|
|
342
|
+
yield head + newline;
|
|
343
|
+
}
|
|
344
|
+
let line = '';
|
|
345
|
+
for (let i = 0; i < fields.length; i++)
|
|
346
|
+
line += (i === 0 ? '' : delimiter) + cell(row[fields[i]]);
|
|
347
|
+
yield line + newline;
|
|
348
|
+
}
|
|
349
|
+
// an explicit field list still deserves its header when there were no
|
|
350
|
+
// records to infer one from
|
|
351
|
+
if (wantHeader && !emittedHeader && fields !== null && options.fields !== undefined) {
|
|
352
|
+
let head = '';
|
|
353
|
+
for (let i = 0; i < fields.length; i++)
|
|
354
|
+
head += (i === 0 ? '' : delimiter) + cell(fields[i]);
|
|
355
|
+
yield head + newline;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
//#endregion
|
|
360
|
+
|
|
361
|
+
export { CsvSyntaxError, CSV_CODES, coerceCsvValue };
|
|
362
|
+
|
|
363
|
+
//#endregion
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
//#region JOSL errors
|
|
2
|
+
// All parse errors carry a 1-based line and column plus an optional `hint`
|
|
3
|
+
// aimed at machine-repair loops: an LLM that produced almost-valid JOSL can
|
|
4
|
+
// be re-prompted with `message` + `hint` to fix its own output, mirroring
|
|
5
|
+
// the suggestion style of the other jaren error classes.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Shared constructor body for the line/column syntax errors. The `name`
|
|
9
|
+
* is passed as a string literal because the bundle is minified and a
|
|
10
|
+
* mangled class name must not leak into `error.name`.
|
|
11
|
+
*/
|
|
12
|
+
class LineColumnSyntaxError extends SyntaxError {
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} name - The public class name for `error.name`
|
|
15
|
+
* @param {string} message - What is wrong
|
|
16
|
+
* @param {number} line - 1-based physical line number
|
|
17
|
+
* @param {number} column - 1-based column number
|
|
18
|
+
* @param {string} [hint] - Repair suggestion for machine-repair loops
|
|
19
|
+
*/
|
|
20
|
+
constructor(name, message, line, column, hint = undefined) {
|
|
21
|
+
super(`${message} at line ${line}, column ${column}${hint ? ` (${hint})` : ''}`);
|
|
22
|
+
this.name = name;
|
|
23
|
+
this.line = line;
|
|
24
|
+
this.column = column;
|
|
25
|
+
this.hint = hint;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Error thrown when JOSL / TOML source text violates the grammar or the
|
|
31
|
+
* table redefinition rules.
|
|
32
|
+
*/
|
|
33
|
+
export class JoslSyntaxError extends LineColumnSyntaxError {
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} message - What is wrong
|
|
36
|
+
* @param {number} line - 1-based physical line number
|
|
37
|
+
* @param {number} column - 1-based column number
|
|
38
|
+
* @param {string} [hint] - Repair suggestion for machine-repair loops
|
|
39
|
+
*/
|
|
40
|
+
constructor(message, line, column, hint = undefined) {
|
|
41
|
+
super('JoslSyntaxError', message, line, column, hint);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Error thrown when a value cannot be represented in the requested output
|
|
47
|
+
* mode (e.g. `null` or a RegExp in strict TOML mode, a scalar root).
|
|
48
|
+
*/
|
|
49
|
+
export class JoslStringifyError extends Error {
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} message - What is wrong
|
|
52
|
+
* @param {(string|number)[]} [path] - Path of the offending value
|
|
53
|
+
*/
|
|
54
|
+
constructor(message, path = []) {
|
|
55
|
+
super(path.length ? `${message} at /${path.join('/')}` : message);
|
|
56
|
+
this.name = 'JoslStringifyError';
|
|
57
|
+
this.path = path;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Error thrown when CSV source text violates RFC 4180 in strict mode.
|
|
63
|
+
*
|
|
64
|
+
* Every condition this reports is also a *repairable* one: `repair: true`
|
|
65
|
+
* turns each into a logged repair instead of a throw, so the same `code`
|
|
66
|
+
* appears either as `error.code` here or as an entry in the reader's
|
|
67
|
+
* repair log. Carrying the code both ways is what lets a caller move
|
|
68
|
+
* between the two modes without re-learning the diagnosis.
|
|
69
|
+
*/
|
|
70
|
+
export class CsvSyntaxError extends SyntaxError {
|
|
71
|
+
/**
|
|
72
|
+
* @param {string} code - Stable `CSV1xxx` diagnosis code
|
|
73
|
+
* @param {string} message - What is wrong
|
|
74
|
+
* @param {number} line - 1-based physical line number
|
|
75
|
+
* @param {number} column - 1-based column number
|
|
76
|
+
* @param {string} [hint] - Repair suggestion for machine-repair loops
|
|
77
|
+
*/
|
|
78
|
+
constructor(code, message, line, column, hint = undefined) {
|
|
79
|
+
super(`${code}: ${message} at line ${line}, column ${column}${hint ? ` (${hint})` : ''}`);
|
|
80
|
+
this.name = 'CsvSyntaxError';
|
|
81
|
+
this.code = code;
|
|
82
|
+
this.line = line;
|
|
83
|
+
this.column = column;
|
|
84
|
+
this.hint = hint;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Error thrown when JSONX source text violates the grammar.
|
|
90
|
+
*/
|
|
91
|
+
export class JsonxSyntaxError extends LineColumnSyntaxError {
|
|
92
|
+
/**
|
|
93
|
+
* @param {string} message - What is wrong
|
|
94
|
+
* @param {number} line - 1-based physical line number
|
|
95
|
+
* @param {number} column - 1-based column number
|
|
96
|
+
* @param {string} [hint] - Repair suggestion for machine-repair loops
|
|
97
|
+
*/
|
|
98
|
+
constructor(message, line, column, hint = undefined) {
|
|
99
|
+
super('JsonxSyntaxError', message, line, column, hint);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
//#endregion
|