@graphty/graph-io 0.3.41 → 0.3.42

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.
Files changed (98) hide show
  1. package/README.md +2 -0
  2. package/dist/chunks/{children-CL3Cy0ez.js → children-CA4coBi0.js} +36 -27
  3. package/dist/chunks/children-CA4coBi0.js.map +1 -0
  4. package/dist/chunks/{choose-CwFQamH9.js → choose-DvpbdRuz.js} +2 -2
  5. package/dist/chunks/{choose-CwFQamH9.js.map → choose-DvpbdRuz.js.map} +1 -1
  6. package/dist/chunks/{constants-Cf4Nn6tw.js → constants-fT3E8JYY.js} +2 -2
  7. package/dist/chunks/{constants-Cf4Nn6tw.js.map → constants-fT3E8JYY.js.map} +1 -1
  8. package/dist/chunks/{escape-DBb8zuNE.js → escape-B62ZGM_a.js} +2 -2
  9. package/dist/chunks/{escape-DBb8zuNE.js.map → escape-B62ZGM_a.js.map} +1 -1
  10. package/dist/chunks/{export-DD3Lvw-F.js → export-rKLeGQMo.js} +232 -128
  11. package/dist/chunks/export-rKLeGQMo.js.map +1 -0
  12. package/dist/chunks/{exporter-CS35R6Fg.js → exporter-8hLNemFk.js} +4 -4
  13. package/dist/chunks/{exporter-CS35R6Fg.js.map → exporter-8hLNemFk.js.map} +1 -1
  14. package/dist/chunks/{exporter-D8r1N2dU.js → exporter-BWwEuOPT.js} +5 -5
  15. package/dist/chunks/{exporter-D8r1N2dU.js.map → exporter-BWwEuOPT.js.map} +1 -1
  16. package/dist/chunks/{exporter-BqtmlxNr.js → exporter-CpRyHZ3H.js} +34 -21
  17. package/dist/chunks/exporter-CpRyHZ3H.js.map +1 -0
  18. package/dist/chunks/{exporter-NMplSnYn.js → exporter-yLxKBvLE.js} +3 -3
  19. package/dist/chunks/{exporter-NMplSnYn.js.map → exporter-yLxKBvLE.js.map} +1 -1
  20. package/dist/chunks/{importer-S-abxJHn.js → importer-BbNb7eNg.js} +111 -71
  21. package/dist/chunks/importer-BbNb7eNg.js.map +1 -0
  22. package/dist/chunks/{importer-BdWHmlmG.js → importer-C98dpSH4.js} +5 -5
  23. package/dist/chunks/{importer-BdWHmlmG.js.map → importer-C98dpSH4.js.map} +1 -1
  24. package/dist/chunks/{importer-r29cWnEf.js → importer-D794XUTe.js} +3 -3
  25. package/dist/chunks/{importer-r29cWnEf.js.map → importer-D794XUTe.js.map} +1 -1
  26. package/dist/chunks/{importer-BfbzWk6k.js → importer-Dz7tI8Sa.js} +5 -5
  27. package/dist/chunks/{importer-BfbzWk6k.js.map → importer-Dz7tI8Sa.js.map} +1 -1
  28. package/dist/chunks/{importer-DviEsHy4.js → importer-Ht7LNg8L.js} +4 -4
  29. package/dist/chunks/{importer-DviEsHy4.js.map → importer-Ht7LNg8L.js.map} +1 -1
  30. package/dist/chunks/{importer-BKC7AmCA.js → importer-WrGRZb0N.js} +6 -6
  31. package/dist/chunks/{importer-BKC7AmCA.js.map → importer-WrGRZb0N.js.map} +1 -1
  32. package/dist/chunks/{importer-BmaZcskq.js → importer-xZ-3suY1.js} +5 -5
  33. package/dist/chunks/{importer-BmaZcskq.js.map → importer-xZ-3suY1.js.map} +1 -1
  34. package/dist/chunks/{json-elements-DdF-etPT.js → json-elements-DKSqpNW1.js} +3 -3
  35. package/dist/chunks/{json-elements-DdF-etPT.js.map → json-elements-DKSqpNW1.js.map} +1 -1
  36. package/dist/chunks/{ontology-export-BC0zyPhj.js → ontology-export-CtRXjg_i.js} +2 -2
  37. package/dist/chunks/{ontology-export-BC0zyPhj.js.map → ontology-export-CtRXjg_i.js.map} +1 -1
  38. package/dist/chunks/{records-Uc3LkABx.js → records-Cu4bxYOm.js} +56 -26
  39. package/dist/chunks/records-Cu4bxYOm.js.map +1 -0
  40. package/dist/chunks/{text-Bh9SaAob.js → text-DHOJXnED.js} +2 -2
  41. package/dist/chunks/{text-Bh9SaAob.js.map → text-DHOJXnED.js.map} +1 -1
  42. package/dist/csv.js +260 -196
  43. package/dist/csv.js.map +1 -1
  44. package/dist/cx.js +1 -1
  45. package/dist/cx2.js +2 -2
  46. package/dist/cys.js +1 -1
  47. package/dist/dot.js +1 -1
  48. package/dist/gexf.js +2 -2
  49. package/dist/gml.js +3 -3
  50. package/dist/graph-io.js +27 -27
  51. package/dist/graphml.js +2 -2
  52. package/dist/json.js +1 -1
  53. package/dist/neo4j.js +3 -3
  54. package/dist/obo.js +1 -1
  55. package/dist/pajek.js +1 -1
  56. package/dist/src/children.d.ts.map +1 -1
  57. package/dist/src/children.js +50 -27
  58. package/dist/src/children.js.map +1 -1
  59. package/dist/src/common/attributes.d.ts.map +1 -1
  60. package/dist/src/common/attributes.js +52 -33
  61. package/dist/src/common/attributes.js.map +1 -1
  62. package/dist/src/common/input.d.ts.map +1 -1
  63. package/dist/src/common/input.js +178 -82
  64. package/dist/src/common/input.js.map +1 -1
  65. package/dist/src/common/lists.js +30 -15
  66. package/dist/src/common/lists.js.map +1 -1
  67. package/dist/src/formats/csv/exporter.d.ts.map +1 -1
  68. package/dist/src/formats/csv/exporter.js +211 -121
  69. package/dist/src/formats/csv/exporter.js.map +1 -1
  70. package/dist/src/formats/csv/importer.d.ts.map +1 -1
  71. package/dist/src/formats/csv/importer.js +113 -74
  72. package/dist/src/formats/csv/importer.js.map +1 -1
  73. package/dist/src/formats/csv/records.d.ts.map +1 -1
  74. package/dist/src/formats/csv/records.js +60 -26
  75. package/dist/src/formats/csv/records.js.map +1 -1
  76. package/dist/src/formats/dot/tokenizer.d.ts +39 -0
  77. package/dist/src/formats/dot/tokenizer.d.ts.map +1 -1
  78. package/dist/src/formats/dot/tokenizer.js +112 -66
  79. package/dist/src/formats/dot/tokenizer.js.map +1 -1
  80. package/dist/src/formats/json/dialect.d.ts.map +1 -1
  81. package/dist/src/formats/json/dialect.js +54 -20
  82. package/dist/src/formats/json/dialect.js.map +1 -1
  83. package/dist/xgmml.js +2 -2
  84. package/package.json +2 -2
  85. package/src/children.ts +54 -27
  86. package/src/common/attributes.ts +59 -30
  87. package/src/common/input.ts +223 -95
  88. package/src/common/lists.ts +29 -15
  89. package/src/formats/csv/exporter.ts +274 -136
  90. package/src/formats/csv/importer.ts +138 -78
  91. package/src/formats/csv/records.ts +71 -25
  92. package/src/formats/dot/tokenizer.ts +118 -68
  93. package/src/formats/json/dialect.ts +61 -22
  94. package/dist/chunks/children-CL3Cy0ez.js.map +0 -1
  95. package/dist/chunks/export-DD3Lvw-F.js.map +0 -1
  96. package/dist/chunks/exporter-BqtmlxNr.js.map +0 -1
  97. package/dist/chunks/importer-S-abxJHn.js.map +0 -1
  98. package/dist/chunks/records-Uc3LkABx.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graphty/graph-io",
3
- "version": "0.3.41",
3
+ "version": "0.3.42",
4
4
  "description": "Read and write 13 graph file formats (JSON, GraphML, GEXF, CSV, GML, DOT, Pajek, Neo4j CSV, XGMML, CX2, CX, OBO, Cytoscape sessions) as @graphty/graph-format snapshots",
5
5
  "author": "Adam Powers <apowers@ato.ms>",
6
6
  "type": "module",
@@ -121,7 +121,7 @@
121
121
  },
122
122
  "homepage": "https://graphty.app/docs/graph-io/",
123
123
  "dependencies": {
124
- "@graphty/graph-format": "^1.4.2"
124
+ "@graphty/graph-format": "^1.4.3"
125
125
  },
126
126
  "peerDependencies": {
127
127
  "@graphty/graph-format": "^1.0.0"
package/src/children.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  * every node is written exactly once.
15
15
  */
16
16
 
17
- import { type Column, type GraphSnapshot, INVALID_INDEX } from "@graphty/graph-format";
17
+ import { type Column, type GraphSnapshot, INVALID_INDEX, type ListColumn } from "@graphty/graph-format";
18
18
 
19
19
  /**
20
20
  * The role of the column a children CSR is built over.
@@ -282,7 +282,6 @@ export function childrenFromColumn(nodeCount: number, column: Column | null): Ch
282
282
  let dropped = 0;
283
283
  const references: number[] = [];
284
284
  if (column !== null) {
285
- const rows = Math.min(column.length, nodeCount);
286
285
  const keep = (child: number, parent: number): void => {
287
286
  if (!Number.isInteger(parent) || parent === INVALID_INDEX || parent >= nodeCount || parent === child) {
288
287
  dropped++;
@@ -292,31 +291,9 @@ export function childrenFromColumn(nodeCount: number, column: Column | null): Ch
292
291
  rowPtr[parent + 1]++;
293
292
  parentCount[child]++;
294
293
  };
295
- if (column.dtype === "u32") {
296
- const { data } = column;
297
- for (let i = 0; i < rows; i++) {
298
- if (column.isSet(i)) {
299
- keep(i, data[i]);
300
- }
301
- }
302
- } else if (column.dtype === "list") {
303
- for (let i = 0; i < rows; i++) {
304
- if (!column.isSet(i)) {
305
- continue;
306
- }
307
- for (const item of column.sliceOf(i)) {
308
- if (typeof item === "number") {
309
- keep(i, item);
310
- } else {
311
- dropped++;
312
- }
313
- }
314
- }
315
- } else {
316
- throw new TypeError(
317
- `a containment column must be u32 or a list of u32, found ${column.dtype} column "${column.meta.name}"`,
318
- );
319
- }
294
+ // keep() counts into `dropped` while it runs, so add the non-numeric items after the call
295
+ const nonNumeric = readContainment(column, Math.min(column.length, nodeCount), keep);
296
+ dropped += nonNumeric;
320
297
  }
321
298
  for (let u = 0; u < nodeCount; u++) {
322
299
  rowPtr[u + 1] += rowPtr[u];
@@ -330,6 +307,56 @@ export function childrenFromColumn(nodeCount: number, column: Column | null): Ch
330
307
  return new ChildrenCsr(nodeCount, rowPtr, children, parentCount, dropped, column);
331
308
  }
332
309
 
310
+ /**
311
+ * Hand every (child, parent) reference of the first `rows` rows of a containment column to `keep`,
312
+ * in ascending child order.
313
+ * @param column - a u32 `parent`-shaped column or a list-of-u32 `parents`-shaped column
314
+ * @param rows - how many rows to read
315
+ * @param keep - receives each reference
316
+ * @returns how many list items were dropped for not being a number
317
+ */
318
+ function readContainment(column: Column, rows: number, keep: (child: number, parent: number) => void): number {
319
+ if (column.dtype === "u32") {
320
+ const { data } = column;
321
+ for (let i = 0; i < rows; i++) {
322
+ if (column.isSet(i)) {
323
+ keep(i, data[i]);
324
+ }
325
+ }
326
+ return 0;
327
+ }
328
+ if (column.dtype === "list") {
329
+ return readParentLists(column, rows, keep);
330
+ }
331
+ throw new TypeError(
332
+ `a containment column must be u32 or a list of u32, found ${column.dtype} column "${column.meta.name}"`,
333
+ );
334
+ }
335
+
336
+ /**
337
+ * Hand every numeric item of a `parents`-shaped list column to `keep`.
338
+ * @param column - the list column
339
+ * @param rows - how many rows to read
340
+ * @param keep - receives each reference
341
+ * @returns how many items were not numbers
342
+ */
343
+ function readParentLists(column: ListColumn, rows: number, keep: (child: number, parent: number) => void): number {
344
+ let dropped = 0;
345
+ for (let i = 0; i < rows; i++) {
346
+ if (!column.isSet(i)) {
347
+ continue;
348
+ }
349
+ for (const item of column.sliceOf(i)) {
350
+ if (typeof item === "number") {
351
+ keep(i, item);
352
+ } else {
353
+ dropped++;
354
+ }
355
+ }
356
+ }
357
+ return dropped;
358
+ }
359
+
333
360
  /**
334
361
  * The column a ChildrenOptions selects.
335
362
  * @param snapshot - the snapshot
@@ -132,22 +132,7 @@ export const DICT_SAMPLE_ROWS = 1024;
132
132
  export function declareAttribute(input: AttributeDeclarationInput): DeclaredAttribute {
133
133
  const issues: DeclarationIssue[] = [];
134
134
  const listSyntax = input.listSyntax ?? "gexf";
135
- let spec: DeclaredTypeSpec;
136
- if (input.type === null) {
137
- spec = stringSpec(null);
138
- } else {
139
- const mapped = mapDeclaredType(input.format, input.type, input.long);
140
- if (mapped === null) {
141
- spec = stringSpec(input.type);
142
- issues.push({
143
- category: "unsupported",
144
- code: UNKNOWN_TYPE_CODE,
145
- message: `${input.format} type "${input.type}" is not supported; values kept as text`,
146
- });
147
- } else {
148
- spec = mapped;
149
- }
150
- }
135
+ let spec = declaredSpec(input, issues);
151
136
  const { name, renamed } = resolveName(input);
152
137
  if (renamed) {
153
138
  issues.push({
@@ -179,20 +164,7 @@ export function declareAttribute(input: AttributeDeclarationInput): DeclaredAttr
179
164
  }
180
165
  const optionsText = input.optionsText ?? null;
181
166
  if (optionsText !== null) {
182
- try {
183
- const options = splitListText(optionsText, listSyntax).map((item) => parseItem(item, spec));
184
- decl.options = options;
185
- if (spec.dtype === "string" && !spec.list && options.every((o) => typeof o === "string")) {
186
- decl.dtype = "dict";
187
- spec = { ...spec, dtype: "dict" };
188
- }
189
- } catch (err) {
190
- issues.push({
191
- category: "validation-error",
192
- code: BAD_OPTIONS_CODE,
193
- message: `options "${optionsText}" of attribute "${name}" do not parse as ${spec.declared}: ${messageOf(err)}`,
194
- });
195
- }
167
+ spec = declareOptions(decl, spec, optionsText, listSyntax, issues);
196
168
  }
197
169
  const defaultText = input.defaultText ?? null;
198
170
  if (defaultText !== null) {
@@ -210,6 +182,63 @@ export function declareAttribute(input: AttributeDeclarationInput): DeclaredAttr
210
182
  return { decl, spec, listSyntax, companion, renamed, issues };
211
183
  }
212
184
 
185
+ /**
186
+ * The value spec of a declared type: string for no type, string with UNKNOWN_TYPE_CODE for a type
187
+ * the format does not define.
188
+ * @param input - what the file declares
189
+ * @param issues - receives the unknown-type warning
190
+ * @returns the spec
191
+ */
192
+ function declaredSpec(input: AttributeDeclarationInput, issues: DeclarationIssue[]): DeclaredTypeSpec {
193
+ if (input.type === null) {
194
+ return stringSpec(null);
195
+ }
196
+ const mapped = mapDeclaredType(input.format, input.type, input.long);
197
+ if (mapped !== null) {
198
+ return mapped;
199
+ }
200
+ issues.push({
201
+ category: "unsupported",
202
+ code: UNKNOWN_TYPE_CODE,
203
+ message: `${input.format} type "${input.type}" is not supported; values kept as text`,
204
+ });
205
+ return stringSpec(input.type);
206
+ }
207
+
208
+ /**
209
+ * Put the parsed options of an attribute on its declaration; a plain string attribute whose
210
+ * options are all strings becomes a dict column.
211
+ * @param decl - the declaration to complete
212
+ * @param spec - the value spec
213
+ * @param optionsText - the options text
214
+ * @param listSyntax - the list syntax the options are split with
215
+ * @param issues - receives BAD_OPTIONS_CODE when the options do not parse
216
+ * @returns the spec, with dtype dict when the column became a dict
217
+ */
218
+ function declareOptions(
219
+ decl: ColumnDecl,
220
+ spec: DeclaredTypeSpec,
221
+ optionsText: string,
222
+ listSyntax: ListSyntax,
223
+ issues: DeclarationIssue[],
224
+ ): DeclaredTypeSpec {
225
+ try {
226
+ const options = splitListText(optionsText, listSyntax).map((item) => parseItem(item, spec));
227
+ decl.options = options;
228
+ if (spec.dtype === "string" && !spec.list && options.every((o) => typeof o === "string")) {
229
+ decl.dtype = "dict";
230
+ return { ...spec, dtype: "dict" };
231
+ }
232
+ } catch (err) {
233
+ issues.push({
234
+ category: "validation-error",
235
+ code: BAD_OPTIONS_CODE,
236
+ message: `options "${optionsText}" of attribute "${decl.name}" do not parse as ${spec.declared}: ${messageOf(err)}`,
237
+ });
238
+ }
239
+ return spec;
240
+ }
241
+
213
242
  /**
214
243
  * Parse a value text by a declaration: a scalar, or a list split by the syntax with each item
215
244
  * parsed by the item kind.
@@ -506,46 +506,81 @@ class ByteDecoder {
506
506
  const bad = this.encoding === "utf-8" ? invalidUtf8(all) : null;
507
507
  const at = base + (bad === null ? 0 : bad.index);
508
508
  if (this.mayFallBack && this.asciiSoFar) {
509
- // windows-1252 text holds no control bytes but TAB, LF, FF and CR: one anywhere in
510
- // undeclared input that is not UTF-8 marks binary data (watch() saw every byte so far)
511
- if (this.control !== null) {
512
- return this.binary(this.control);
513
- }
514
- if (this.textFollows && bad?.cut === true) {
515
- return this.report.fail(
516
- INVALID_UTF8_CODE,
517
- `invalid UTF-8 at byte ${at}: a byte chunk ends inside a multi-byte sequence and a text chunk follows`,
518
- undefined,
519
- { byteOffset: at },
520
- );
521
- }
522
- if (!stream && bad?.truncated === true) {
523
- return this.report.fail(
524
- INVALID_UTF8_CODE,
525
- `the input ends in the middle of a UTF-8 sequence at byte ${at} (it may be truncated)`,
526
- undefined,
527
- { byteOffset: at },
528
- );
529
- }
530
- if (!startsWithUtf8(all)) {
531
- const cutNote =
532
- !stream && bad?.cut === true
533
- ? ": it ends inside a multi-byte UTF-8 sequence, so it may be truncated"
534
- : "";
535
- this.report.warning(
536
- "coercion",
537
- ENCODING_FALLBACK_CODE,
538
- this.declaredUtf8
539
- ? `the input declares UTF-8 but is not valid UTF-8 (at byte ${at}); everything before was ASCII, so it is read as windows-1252 (pass the encoding option to choose another)`
540
- : `the input is not valid UTF-8 (at byte ${at}${cutNote}) and declares no encoding; read as windows-1252 (pass the encoding option to choose another)`,
541
- );
542
- this.use("windows-1252", false);
543
- this.fellBack = true;
544
- this.mixedPending = true;
545
- this.reportUtf8After(all, base);
546
- return (this.decoder as TextDecoder).decode(all, { stream });
509
+ const text = this.fallBack(all, bad, at, base, stream);
510
+ if (text !== null) {
511
+ return text;
547
512
  }
548
513
  }
514
+ return this.failDecode(bytes, at, base, stream);
515
+ }
516
+
517
+ /**
518
+ * Undeclared input that was ASCII so far failed to decode: fail on binary data or a cut
519
+ * sequence, else switch to windows-1252 with a warning unless the bytes start with valid
520
+ * non-ASCII UTF-8.
521
+ * @param all - the held-back bytes followed by the bytes that failed
522
+ * @param bad - the first invalid UTF-8 sequence, or null when the encoding is not UTF-8
523
+ * @param at - the byte offset of the first invalid byte
524
+ * @param base - the byte offset of `all`
525
+ * @param stream - whether more bytes follow
526
+ * @returns the bytes decoded as windows-1252, or null when they do not fall back
527
+ */
528
+ private fallBack(
529
+ all: Uint8Array,
530
+ bad: Utf8Problem | null,
531
+ at: number,
532
+ base: number,
533
+ stream: boolean,
534
+ ): string | null {
535
+ // windows-1252 text holds no control bytes but TAB, LF, FF and CR: one anywhere in
536
+ // undeclared input that is not UTF-8 marks binary data (watch() saw every byte so far)
537
+ if (this.control !== null) {
538
+ return this.binary(this.control);
539
+ }
540
+ if (this.textFollows && bad?.cut === true) {
541
+ return this.report.fail(
542
+ INVALID_UTF8_CODE,
543
+ `invalid UTF-8 at byte ${at}: a byte chunk ends inside a multi-byte sequence and a text chunk follows`,
544
+ undefined,
545
+ { byteOffset: at },
546
+ );
547
+ }
548
+ if (!stream && bad?.truncated === true) {
549
+ return this.report.fail(
550
+ INVALID_UTF8_CODE,
551
+ `the input ends in the middle of a UTF-8 sequence at byte ${at} (it may be truncated)`,
552
+ undefined,
553
+ { byteOffset: at },
554
+ );
555
+ }
556
+ if (startsWithUtf8(all)) {
557
+ return null;
558
+ }
559
+ const cutNote =
560
+ !stream && bad?.cut === true ? ": it ends inside a multi-byte UTF-8 sequence, so it may be truncated" : "";
561
+ this.report.warning(
562
+ "coercion",
563
+ ENCODING_FALLBACK_CODE,
564
+ this.declaredUtf8
565
+ ? `the input declares UTF-8 but is not valid UTF-8 (at byte ${at}); everything before was ASCII, so it is read as windows-1252 (pass the encoding option to choose another)`
566
+ : `the input is not valid UTF-8 (at byte ${at}${cutNote}) and declares no encoding; read as windows-1252 (pass the encoding option to choose another)`,
567
+ );
568
+ this.use("windows-1252", false);
569
+ this.fellBack = true;
570
+ this.mixedPending = true;
571
+ this.reportUtf8After(all, base);
572
+ return (this.decoder as TextDecoder).decode(all, { stream });
573
+ }
574
+
575
+ /**
576
+ * Fail the import on bytes that are not valid in the encoding.
577
+ * @param bytes - the bytes that failed
578
+ * @param at - the byte offset of the first invalid UTF-8 byte
579
+ * @param base - the byte offset of the held-back bytes and these
580
+ * @param stream - whether more bytes follow
581
+ * @returns never: it throws
582
+ */
583
+ private failDecode(bytes: Uint8Array, at: number, base: number, stream: boolean): never {
549
584
  if (this.encoding === "utf-8") {
550
585
  const after = this.mayFallBack ? " after valid non-ASCII UTF-8 text; pass the encoding option" : "";
551
586
  return this.report.fail(INVALID_UTF8_CODE, `invalid UTF-8 at byte ${at}${after}`, undefined, {
@@ -1662,81 +1697,174 @@ export class LineReader implements AsyncIterable<string> {
1662
1697
  * @returns nothing
1663
1698
  */
1664
1699
  async *[Symbol.asyncIterator](): AsyncGenerator<string, void, undefined> {
1665
- // The pieces of the line in progress (none of them holds a line break, except that the last
1666
- // may end with a `\r` whose meaning the next chunk decides); joined once when the line ends,
1667
- // so a line spanning many chunks costs its length, not its length times the chunk count.
1668
- const pending: string[] = [];
1669
- let pendingLength = 0;
1700
+ // The pieces of the line in progress, joined once when the line ends, so a line spanning
1701
+ // many chunks costs its length, not its length times the chunk count.
1702
+ const pending = new PendingLine((line) => tooLarge(this.report, this.lineTooLong(), line));
1670
1703
  let trailingCr = false;
1671
1704
  for await (const chunk of textChunks(this.input, this.report, this.options)) {
1672
1705
  let start = 0;
1673
- const end = chunk.length;
1674
1706
  if (trailingCr) {
1675
1707
  // the previous chunk ended with `\r`: that ended a line, and a leading `\n` here is
1676
1708
  // the second half of the same terminator
1677
1709
  trailingCr = false;
1678
1710
  this.lineNumber++;
1679
- const joined = pending.join("");
1680
- pending.length = 0;
1681
- pendingLength = 0;
1682
- yield joined.slice(0, -1);
1711
+ yield pending.take().slice(0, -1);
1683
1712
  if (chunk.charCodeAt(0) === 10) {
1684
1713
  start = 1;
1685
1714
  }
1686
1715
  }
1687
- // the next `\n` and `\r` at or after `start`; each is searched for once per chunk and
1688
- // again only after it was consumed, so a chunk is scanned once whatever its line count
1689
- let nl = chunk.indexOf("\n", start);
1690
- let cr = chunk.indexOf("\r", start);
1691
- while (nl >= 0 || cr >= 0) {
1692
- let cut: number;
1693
- let next: number;
1694
- if (cr >= 0 && (nl < 0 || cr < nl)) {
1695
- if (cr === end - 1) {
1696
- // a trailing \r may be the first half of \r\n split across chunks
1697
- break;
1698
- }
1699
- cut = cr;
1700
- next = chunk.charCodeAt(cr + 1) === 10 ? cr + 2 : cr + 1;
1701
- } else {
1702
- cut = nl;
1703
- next = nl + 1;
1704
- }
1716
+ const breaks = new LineBreaks(chunk, start);
1717
+ while (breaks.advance()) {
1705
1718
  this.lineNumber++;
1706
- const piece = chunk.slice(start, cut);
1707
- if (pending.length === 0) {
1708
- yield piece;
1709
- } else {
1710
- if (pendingLength + piece.length > MAX_TEXT_LENGTH) {
1711
- tooLarge(this.report, this.lineTooLong(), this.lineNumber);
1712
- }
1713
- pending.push(piece);
1714
- const joined = pending.join("");
1715
- pending.length = 0;
1716
- pendingLength = 0;
1717
- yield joined;
1718
- }
1719
- start = next;
1720
- if (nl >= 0 && nl < next) {
1721
- nl = chunk.indexOf("\n", next);
1722
- }
1723
- if (cr >= 0 && cr < next) {
1724
- cr = chunk.indexOf("\r", next);
1725
- }
1719
+ yield pending.finish(chunk.slice(start, breaks.cut), this.lineNumber);
1720
+ start = breaks.next;
1726
1721
  }
1727
- if (start < end) {
1728
- pendingLength += end - start;
1729
- if (pendingLength > MAX_TEXT_LENGTH) {
1730
- tooLarge(this.report, this.lineTooLong(), this.lineNumber + 1);
1731
- }
1732
- pending.push(start === 0 ? chunk : chunk.slice(start));
1733
- trailingCr = chunk.charCodeAt(end - 1) === 13;
1722
+ if (start < chunk.length) {
1723
+ pending.appendRest(chunk, start, this.lineNumber + 1);
1724
+ trailingCr = chunk.codePointAt(chunk.length - 1) === 13;
1734
1725
  }
1735
1726
  }
1736
- if (pending.length > 0) {
1727
+ if (!pending.empty) {
1737
1728
  this.lineNumber++;
1738
- const joined = pending.join("");
1729
+ const joined = pending.take();
1739
1730
  yield joined.endsWith("\r") ? joined.slice(0, -1) : joined;
1740
1731
  }
1741
1732
  }
1742
1733
  }
1734
+
1735
+ /**
1736
+ * The pieces of a line that spans chunks. None of them holds a line break, except that the last
1737
+ * may end with a `\r` whose meaning the next chunk decides.
1738
+ */
1739
+ class PendingLine {
1740
+ private readonly pieces: string[] = [];
1741
+
1742
+ private length = 0;
1743
+
1744
+ private readonly tooLong: (line: number) => never;
1745
+
1746
+ /**
1747
+ * Start an empty line.
1748
+ * @param tooLong - fails the import when the line grows longer than one string can hold
1749
+ */
1750
+ constructor(tooLong: (line: number) => never) {
1751
+ this.tooLong = tooLong;
1752
+ }
1753
+
1754
+ /**
1755
+ * Whether no piece is held.
1756
+ * @returns true when the line in progress is empty
1757
+ */
1758
+ get empty(): boolean {
1759
+ return this.pieces.length === 0;
1760
+ }
1761
+
1762
+ /**
1763
+ * Hold the rest of a chunk from `start`: the line goes on in the next chunk.
1764
+ * @param chunk - the chunk
1765
+ * @param start - where the unfinished line starts in it
1766
+ * @param line - the 1-based number of the line, for the too-long error
1767
+ */
1768
+ appendRest(chunk: string, start: number, line: number): void {
1769
+ this.append(start === 0 ? chunk : chunk.slice(start), line);
1770
+ }
1771
+
1772
+ /**
1773
+ * End the line with its last piece.
1774
+ * @param piece - the text from the start of the chunk (or the last break) to the line break
1775
+ * @param line - the 1-based number of the line, for the too-long error
1776
+ * @returns the whole line
1777
+ */
1778
+ finish(piece: string, line: number): string {
1779
+ if (this.pieces.length === 0) {
1780
+ return piece;
1781
+ }
1782
+ this.append(piece, line);
1783
+ return this.take();
1784
+ }
1785
+
1786
+ /**
1787
+ * Join the held pieces and start a new line.
1788
+ * @returns the joined text
1789
+ */
1790
+ take(): string {
1791
+ const joined = this.pieces.join("");
1792
+ this.pieces.length = 0;
1793
+ this.length = 0;
1794
+ return joined;
1795
+ }
1796
+
1797
+ /**
1798
+ * Hold one more piece.
1799
+ * @param piece - the piece
1800
+ * @param line - the 1-based number of the line, for the too-long error
1801
+ */
1802
+ private append(piece: string, line: number): void {
1803
+ this.length += piece.length;
1804
+ if (this.length > MAX_TEXT_LENGTH) {
1805
+ this.tooLong(line);
1806
+ }
1807
+ this.pieces.push(piece);
1808
+ }
1809
+ }
1810
+
1811
+ /**
1812
+ * The line breaks of one chunk, in order: `\n`, `\r\n` and a lone `\r`. Each of `\n` and `\r` is
1813
+ * searched for once per chunk and again only after it was consumed, so a chunk is scanned once
1814
+ * whatever its line count.
1815
+ */
1816
+ class LineBreaks {
1817
+ /** Where the line just found ends (its terminator starts there). */
1818
+ cut = 0;
1819
+
1820
+ /** Where the next line starts. */
1821
+ next: number;
1822
+
1823
+ private readonly chunk: string;
1824
+
1825
+ private nl: number;
1826
+
1827
+ private cr: number;
1828
+
1829
+ /**
1830
+ * Scan a chunk from a position.
1831
+ * @param chunk - the chunk
1832
+ * @param start - where scanning starts
1833
+ */
1834
+ constructor(chunk: string, start: number) {
1835
+ this.chunk = chunk;
1836
+ this.next = start;
1837
+ this.nl = chunk.indexOf("\n", start);
1838
+ this.cr = chunk.indexOf("\r", start);
1839
+ }
1840
+
1841
+ /**
1842
+ * Find the next line break.
1843
+ * @returns false when none is left, or the only one left is a trailing `\r` (it may be the first
1844
+ * half of a `\r\n` split across chunks)
1845
+ */
1846
+ advance(): boolean {
1847
+ const { chunk, next } = this;
1848
+ if (this.nl >= 0 && this.nl < next) {
1849
+ this.nl = chunk.indexOf("\n", next);
1850
+ }
1851
+ if (this.cr >= 0 && this.cr < next) {
1852
+ this.cr = chunk.indexOf("\r", next);
1853
+ }
1854
+ const { nl, cr } = this;
1855
+ if (cr >= 0 && (nl < 0 || cr < nl)) {
1856
+ if (cr === chunk.length - 1) {
1857
+ return false;
1858
+ }
1859
+ this.cut = cr;
1860
+ this.next = chunk.codePointAt(cr + 1) === 10 ? cr + 2 : cr + 1;
1861
+ return true;
1862
+ }
1863
+ if (nl < 0) {
1864
+ return false;
1865
+ }
1866
+ this.cut = nl;
1867
+ this.next = nl + 1;
1868
+ return true;
1869
+ }
1870
+ }
@@ -144,21 +144,9 @@ function splitBracketed(text: string): string[] {
144
144
  }
145
145
  let item = "";
146
146
  if (i < n && (inner[i] === '"' || inner[i] === "'")) {
147
- const quote = inner[i];
148
- i++;
149
- while (i < n) {
150
- if (inner[i] === quote) {
151
- if (inner[i + 1] === quote) {
152
- item += quote;
153
- i += 2;
154
- continue;
155
- }
156
- i++;
157
- break;
158
- }
159
- item += inner[i];
160
- i++;
161
- }
147
+ const quoted = readQuoted(inner, i);
148
+ item = quoted.text;
149
+ i = quoted.end;
162
150
  }
163
151
  // unquoted text, or text after a closing quote that is not a separator, runs to the next comma
164
152
  let end = inner.indexOf(",", i);
@@ -179,6 +167,32 @@ function splitBracketed(text: string): string[] {
179
167
  }
180
168
  }
181
169
 
170
+ /**
171
+ * Read a quoted item of a bracketed list: a doubled quote inside it is one quote, and a missing
172
+ * closing quote ends it at the end of the text.
173
+ * @param inner - the inside of the brackets
174
+ * @param open - the index of the opening quote
175
+ * @returns the unquoted text and the index just past the closing quote
176
+ */
177
+ function readQuoted(inner: string, open: number): { text: string; end: number } {
178
+ const quote = inner[open];
179
+ const n = inner.length;
180
+ let text = "";
181
+ let i = open + 1;
182
+ while (i < n) {
183
+ if (inner[i] !== quote) {
184
+ text += inner[i];
185
+ i++;
186
+ } else if (inner[i + 1] === quote) {
187
+ text += quote;
188
+ i += 2;
189
+ } else {
190
+ return { text, end: i + 1 };
191
+ }
192
+ }
193
+ return { text, end: i };
194
+ }
195
+
182
196
  /**
183
197
  * Whether a char code is ASCII whitespace.
184
198
  * @param c - the char code