@graphty/graph-io 0.3.39 → 0.3.41

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 (213) hide show
  1. package/dist/chunks/{choose-CS3fvGT1.js → choose-CwFQamH9.js} +2 -2
  2. package/dist/chunks/{choose-CS3fvGT1.js.map → choose-CwFQamH9.js.map} +1 -1
  3. package/dist/chunks/{constants-DqKZT6cH.js → constants-Cf4Nn6tw.js} +2 -2
  4. package/dist/chunks/{constants-DqKZT6cH.js.map → constants-Cf4Nn6tw.js.map} +1 -1
  5. package/dist/chunks/{escape-BpYMQZVa.js → escape-DBb8zuNE.js} +4 -3
  6. package/dist/chunks/escape-DBb8zuNE.js.map +1 -0
  7. package/dist/chunks/{export-B-aVCwyG.js → export-DD3Lvw-F.js} +173 -116
  8. package/dist/chunks/export-DD3Lvw-F.js.map +1 -0
  9. package/dist/chunks/{exporter-CNJZF_BN.js → exporter-BqtmlxNr.js} +545 -459
  10. package/dist/chunks/exporter-BqtmlxNr.js.map +1 -0
  11. package/dist/chunks/{exporter-BqKs1Q12.js → exporter-CS35R6Fg.js} +6 -5
  12. package/dist/chunks/exporter-CS35R6Fg.js.map +1 -0
  13. package/dist/chunks/{exporter-DXqCPNrI.js → exporter-D8r1N2dU.js} +67 -42
  14. package/dist/chunks/exporter-D8r1N2dU.js.map +1 -0
  15. package/dist/chunks/{exporter-SFYkDzD0.js → exporter-NMplSnYn.js} +23 -20
  16. package/dist/chunks/{exporter-SFYkDzD0.js.map → exporter-NMplSnYn.js.map} +1 -1
  17. package/dist/chunks/{importer-CSt-gaYE.js → importer-BKC7AmCA.js} +42 -29
  18. package/dist/chunks/importer-BKC7AmCA.js.map +1 -0
  19. package/dist/chunks/{importer-DvFznt5-.js → importer-BdWHmlmG.js} +112 -62
  20. package/dist/chunks/importer-BdWHmlmG.js.map +1 -0
  21. package/dist/chunks/{importer-CqgYq4Ld.js → importer-BfbzWk6k.js} +202 -181
  22. package/dist/chunks/importer-BfbzWk6k.js.map +1 -0
  23. package/dist/chunks/{importer-PVXkeoaS.js → importer-BmaZcskq.js} +79 -66
  24. package/dist/chunks/importer-BmaZcskq.js.map +1 -0
  25. package/dist/chunks/{importer-B7Ir269L.js → importer-DviEsHy4.js} +118 -72
  26. package/dist/chunks/importer-DviEsHy4.js.map +1 -0
  27. package/dist/chunks/{importer--tQQsY4W.js → importer-S-abxJHn.js} +5 -5
  28. package/dist/chunks/{importer--tQQsY4W.js.map → importer-S-abxJHn.js.map} +1 -1
  29. package/dist/chunks/{importer-BfJ1jnNr.js → importer-r29cWnEf.js} +28 -13
  30. package/dist/chunks/importer-r29cWnEf.js.map +1 -0
  31. package/dist/chunks/{json-elements-CXs4z1iL.js → json-elements-DdF-etPT.js} +7 -5
  32. package/dist/chunks/{json-elements-CXs4z1iL.js.map → json-elements-DdF-etPT.js.map} +1 -1
  33. package/dist/chunks/{ontology-export-50L9DOV4.js → ontology-export-BC0zyPhj.js} +2 -2
  34. package/dist/chunks/{ontology-export-50L9DOV4.js.map → ontology-export-BC0zyPhj.js.map} +1 -1
  35. package/dist/chunks/{records-C9jd7yLD.js → records-Uc3LkABx.js} +150 -106
  36. package/dist/chunks/records-Uc3LkABx.js.map +1 -0
  37. package/dist/chunks/{text-Bjfa1xYL.js → text-Bh9SaAob.js} +20 -11
  38. package/dist/chunks/text-Bh9SaAob.js.map +1 -0
  39. package/dist/csv.js +67 -49
  40. package/dist/csv.js.map +1 -1
  41. package/dist/cx.js +1 -1
  42. package/dist/cx2.js +2 -2
  43. package/dist/cys.js +1 -1
  44. package/dist/dot.js +1 -1
  45. package/dist/gexf.js +199 -99
  46. package/dist/gexf.js.map +1 -1
  47. package/dist/gml.js +170 -149
  48. package/dist/gml.js.map +1 -1
  49. package/dist/graph-io.js +26 -26
  50. package/dist/graphml.js +2 -2
  51. package/dist/json.js +1 -1
  52. package/dist/neo4j.js +103 -65
  53. package/dist/neo4j.js.map +1 -1
  54. package/dist/obo.js +1 -1
  55. package/dist/pajek.js +1 -1
  56. package/dist/src/common/attributes.d.ts.map +1 -1
  57. package/dist/src/common/attributes.js +34 -9
  58. package/dist/src/common/attributes.js.map +1 -1
  59. package/dist/src/common/input.d.ts.map +1 -1
  60. package/dist/src/common/input.js +180 -102
  61. package/dist/src/common/input.js.map +1 -1
  62. package/dist/src/common/json-elements.d.ts.map +1 -1
  63. package/dist/src/common/json-elements.js +4 -2
  64. package/dist/src/common/json-elements.js.map +1 -1
  65. package/dist/src/common/text.d.ts +7 -0
  66. package/dist/src/common/text.d.ts.map +1 -1
  67. package/dist/src/common/text.js +15 -7
  68. package/dist/src/common/text.js.map +1 -1
  69. package/dist/src/common/xml.d.ts.map +1 -1
  70. package/dist/src/common/xml.js +2 -1
  71. package/dist/src/common/xml.js.map +1 -1
  72. package/dist/src/common/zip.d.ts.map +1 -1
  73. package/dist/src/common/zip.js +36 -19
  74. package/dist/src/common/zip.js.map +1 -1
  75. package/dist/src/formats/csv/exporter.d.ts.map +1 -1
  76. package/dist/src/formats/csv/exporter.js +77 -25
  77. package/dist/src/formats/csv/exporter.js.map +1 -1
  78. package/dist/src/formats/csv/importer.d.ts.map +1 -1
  79. package/dist/src/formats/csv/importer.js +16 -6
  80. package/dist/src/formats/csv/importer.js.map +1 -1
  81. package/dist/src/formats/csv/records.d.ts.map +1 -1
  82. package/dist/src/formats/csv/records.js +156 -110
  83. package/dist/src/formats/csv/records.js.map +1 -1
  84. package/dist/src/formats/cx/importer.d.ts.map +1 -1
  85. package/dist/src/formats/cx/importer.js +108 -53
  86. package/dist/src/formats/cx/importer.js.map +1 -1
  87. package/dist/src/formats/cx2/exporter.d.ts.map +1 -1
  88. package/dist/src/formats/cx2/exporter.js +2 -1
  89. package/dist/src/formats/cx2/exporter.js.map +1 -1
  90. package/dist/src/formats/cx2/importer.d.ts.map +1 -1
  91. package/dist/src/formats/cx2/importer.js +28 -6
  92. package/dist/src/formats/cx2/importer.js.map +1 -1
  93. package/dist/src/formats/cys/tables.d.ts.map +1 -1
  94. package/dist/src/formats/cys/tables.js +11 -8
  95. package/dist/src/formats/cys/tables.js.map +1 -1
  96. package/dist/src/formats/gexf/exporter.d.ts.map +1 -1
  97. package/dist/src/formats/gexf/exporter.js +69 -39
  98. package/dist/src/formats/gexf/exporter.js.map +1 -1
  99. package/dist/src/formats/gexf/importer.d.ts.map +1 -1
  100. package/dist/src/formats/gexf/importer.js +138 -50
  101. package/dist/src/formats/gexf/importer.js.map +1 -1
  102. package/dist/src/formats/gml/exporter.d.ts.map +1 -1
  103. package/dist/src/formats/gml/exporter.js +51 -35
  104. package/dist/src/formats/gml/exporter.js.map +1 -1
  105. package/dist/src/formats/gml/importer.d.ts.map +1 -1
  106. package/dist/src/formats/gml/importer.js +103 -58
  107. package/dist/src/formats/gml/importer.js.map +1 -1
  108. package/dist/src/formats/gml/syntax.d.ts.map +1 -1
  109. package/dist/src/formats/gml/syntax.js +17 -8
  110. package/dist/src/formats/gml/syntax.js.map +1 -1
  111. package/dist/src/formats/graphml/exporter.d.ts.map +1 -1
  112. package/dist/src/formats/graphml/exporter.js +36 -25
  113. package/dist/src/formats/graphml/exporter.js.map +1 -1
  114. package/dist/src/formats/graphml/importer.d.ts.map +1 -1
  115. package/dist/src/formats/graphml/importer.js +20 -15
  116. package/dist/src/formats/graphml/importer.js.map +1 -1
  117. package/dist/src/formats/graphml/tree.js +23 -14
  118. package/dist/src/formats/graphml/tree.js.map +1 -1
  119. package/dist/src/formats/graphml/yfiles.d.ts.map +1 -1
  120. package/dist/src/formats/graphml/yfiles.js +22 -10
  121. package/dist/src/formats/graphml/yfiles.js.map +1 -1
  122. package/dist/src/formats/json/exporter.d.ts.map +1 -1
  123. package/dist/src/formats/json/exporter.js +38 -21
  124. package/dist/src/formats/json/exporter.js.map +1 -1
  125. package/dist/src/formats/json/importer.d.ts.map +1 -1
  126. package/dist/src/formats/json/importer.js +536 -343
  127. package/dist/src/formats/json/importer.js.map +1 -1
  128. package/dist/src/formats/json/obographs.js +29 -17
  129. package/dist/src/formats/json/obographs.js.map +1 -1
  130. package/dist/src/formats/neo4j/exporter.d.ts.map +1 -1
  131. package/dist/src/formats/neo4j/exporter.js +73 -43
  132. package/dist/src/formats/neo4j/exporter.js.map +1 -1
  133. package/dist/src/formats/neo4j/header.d.ts.map +1 -1
  134. package/dist/src/formats/neo4j/header.js +37 -16
  135. package/dist/src/formats/neo4j/header.js.map +1 -1
  136. package/dist/src/formats/neo4j/importer.d.ts.map +1 -1
  137. package/dist/src/formats/neo4j/importer.js +21 -11
  138. package/dist/src/formats/neo4j/importer.js.map +1 -1
  139. package/dist/src/formats/obo/exporter.d.ts.map +1 -1
  140. package/dist/src/formats/obo/exporter.js +18 -7
  141. package/dist/src/formats/obo/exporter.js.map +1 -1
  142. package/dist/src/formats/pajek/exporter.d.ts.map +1 -1
  143. package/dist/src/formats/pajek/exporter.js +142 -73
  144. package/dist/src/formats/pajek/exporter.js.map +1 -1
  145. package/dist/src/formats/pajek/importer.d.ts.map +1 -1
  146. package/dist/src/formats/pajek/importer.js +26 -15
  147. package/dist/src/formats/pajek/importer.js.map +1 -1
  148. package/dist/src/formats/pajek/syntax.d.ts.map +1 -1
  149. package/dist/src/formats/pajek/syntax.js +85 -58
  150. package/dist/src/formats/pajek/syntax.js.map +1 -1
  151. package/dist/src/formats/xgmml/emit.d.ts +13 -0
  152. package/dist/src/formats/xgmml/emit.d.ts.map +1 -1
  153. package/dist/src/formats/xgmml/emit.js +112 -50
  154. package/dist/src/formats/xgmml/emit.js.map +1 -1
  155. package/dist/src/formats/xgmml/exporter.d.ts.map +1 -1
  156. package/dist/src/formats/xgmml/exporter.js +92 -34
  157. package/dist/src/formats/xgmml/exporter.js.map +1 -1
  158. package/dist/src/formats/xgmml/importer.d.ts.map +1 -1
  159. package/dist/src/formats/xgmml/importer.js +38 -18
  160. package/dist/src/formats/xgmml/importer.js.map +1 -1
  161. package/dist/src/sniff.js +109 -57
  162. package/dist/src/sniff.js.map +1 -1
  163. package/dist/xgmml.js +2 -2
  164. package/package.json +2 -2
  165. package/src/common/attributes.ts +59 -19
  166. package/src/common/input.ts +216 -102
  167. package/src/common/json-elements.ts +4 -2
  168. package/src/common/text.ts +24 -15
  169. package/src/common/xml.ts +2 -1
  170. package/src/common/zip.ts +47 -18
  171. package/src/formats/csv/exporter.ts +103 -39
  172. package/src/formats/csv/importer.ts +17 -6
  173. package/src/formats/csv/records.ts +162 -109
  174. package/src/formats/cx/importer.ts +140 -58
  175. package/src/formats/cx2/exporter.ts +2 -1
  176. package/src/formats/cx2/importer.ts +34 -10
  177. package/src/formats/cys/tables.ts +11 -10
  178. package/src/formats/gexf/exporter.ts +71 -38
  179. package/src/formats/gexf/importer.ts +173 -63
  180. package/src/formats/gml/exporter.ts +78 -82
  181. package/src/formats/gml/importer.ts +119 -68
  182. package/src/formats/gml/syntax.ts +18 -8
  183. package/src/formats/graphml/exporter.ts +46 -27
  184. package/src/formats/graphml/importer.ts +21 -13
  185. package/src/formats/graphml/tree.ts +22 -12
  186. package/src/formats/graphml/yfiles.ts +23 -10
  187. package/src/formats/json/exporter.ts +40 -21
  188. package/src/formats/json/importer.ts +701 -385
  189. package/src/formats/json/obographs.ts +35 -15
  190. package/src/formats/neo4j/exporter.ts +79 -41
  191. package/src/formats/neo4j/header.ts +50 -16
  192. package/src/formats/neo4j/importer.ts +21 -10
  193. package/src/formats/obo/exporter.ts +34 -17
  194. package/src/formats/pajek/exporter.ts +180 -117
  195. package/src/formats/pajek/importer.ts +27 -15
  196. package/src/formats/pajek/syntax.ts +99 -58
  197. package/src/formats/xgmml/emit.ts +122 -50
  198. package/src/formats/xgmml/exporter.ts +103 -35
  199. package/src/formats/xgmml/importer.ts +45 -23
  200. package/src/sniff.ts +115 -59
  201. package/dist/chunks/escape-BpYMQZVa.js.map +0 -1
  202. package/dist/chunks/export-B-aVCwyG.js.map +0 -1
  203. package/dist/chunks/exporter-BqKs1Q12.js.map +0 -1
  204. package/dist/chunks/exporter-CNJZF_BN.js.map +0 -1
  205. package/dist/chunks/exporter-DXqCPNrI.js.map +0 -1
  206. package/dist/chunks/importer-B7Ir269L.js.map +0 -1
  207. package/dist/chunks/importer-BfJ1jnNr.js.map +0 -1
  208. package/dist/chunks/importer-CSt-gaYE.js.map +0 -1
  209. package/dist/chunks/importer-CqgYq4Ld.js.map +0 -1
  210. package/dist/chunks/importer-DvFznt5-.js.map +0 -1
  211. package/dist/chunks/importer-PVXkeoaS.js.map +0 -1
  212. package/dist/chunks/records-C9jd7yLD.js.map +0 -1
  213. package/dist/chunks/text-Bjfa1xYL.js.map +0 -1
@@ -407,6 +407,25 @@ function nodeTableNote(names: readonly string[], table: string, notes: LossNote[
407
407
  }
408
408
  }
409
409
 
410
+ /**
411
+ * The number of node ids whose text an earlier id already has (a number and a string).
412
+ * @param ids - the snapshot's ids
413
+ * @returns the collisions
414
+ */
415
+ function idTextCollisions(ids: GraphSnapshot["ids"]): number {
416
+ const seen = new Set<string>();
417
+ let collisions = 0;
418
+ for (let i = 0; i < ids.size; i++) {
419
+ const text = String(ids.idOf(i));
420
+ if (seen.has(text)) {
421
+ collisions++;
422
+ } else {
423
+ seen.add(text);
424
+ }
425
+ }
426
+ return collisions;
427
+ }
428
+
410
429
  /**
411
430
  * The id notes: text collisions are fatal (export() throws E_INVALID_ID); type changes under the
412
431
  * canonical re-read are reported.
@@ -421,16 +440,7 @@ function idNotes(
421
440
  ): void {
422
441
  const { ids } = snapshot;
423
442
  if (ids.kind === "mixed") {
424
- const seen = new Set<string>();
425
- let collisions = 0;
426
- for (let i = 0; i < ids.size; i++) {
427
- const text = String(ids.idOf(i));
428
- if (seen.has(text)) {
429
- collisions++;
430
- } else {
431
- seen.add(text);
432
- }
433
- }
443
+ const collisions = idTextCollisions(ids);
434
444
  if (collisions > 0) {
435
445
  note(
436
446
  CSV_LOSS.ID_TEXT_COLLISION,
@@ -699,40 +709,81 @@ function attributeColumns(
699
709
  if (role !== null && SKIPPED_ROLES.has(role)) {
700
710
  continue;
701
711
  }
702
- const low = lower(name);
703
712
  const set = column.length - column.nullCount;
704
- const isIdName = domain === "edge" ? findColumn([name], EDGE_ID_NAMES) >= 0 : false;
705
- if (reserved.has(low) || isIdName || (idColumn !== null && lower(idColumn.meta.name) === low)) {
706
- note?.(
707
- CSV_LOSS.RESERVED_NAME,
708
- `${domain} column "${name}" is not written: the name is reserved for the ${isIdName ? "edge id" : low} column`,
709
- name,
710
- set,
711
- );
713
+ if (isReservedName(name, domain, reserved, idColumn, set, note)) {
712
714
  continue;
713
715
  }
714
- if (findColumn([name], LABEL_NAMES) >= 0) {
715
- if (labelColumn !== null) {
716
- note?.(
717
- CSV_LOSS.RESERVED_NAME,
718
- `${domain} column "${name}" is not written: the name is reserved for the label column`,
719
- name,
720
- set,
721
- );
722
- continue;
723
- }
724
- note?.(
725
- CSV_LOSS.ROLE_ASSUMED,
726
- `${domain} column "${name}" reads back with the label role (string)`,
727
- name,
728
- set,
729
- );
716
+ if (findColumn([name], LABEL_NAMES) >= 0 && !labelNameWritten(name, domain, labelColumn, set, note)) {
717
+ continue;
730
718
  }
731
719
  out.push({ column, header: name });
732
720
  }
733
721
  return out;
734
722
  }
735
723
 
724
+ /**
725
+ * Whether an attribute column's name is reserved for a structural column (a dialect header, an
726
+ * edge id name, the id column's name), with its note.
727
+ * @param name - the column name
728
+ * @param domain - node or edge, for messages
729
+ * @param reserved - lower-case header names the dialect writes for structure
730
+ * @param idColumn - the table's id role column, or null
731
+ * @param set - the column's set cells
732
+ * @param note - the recorder, or null when this table's notes are not wanted
733
+ * @returns true when the column is not written
734
+ */
735
+ function isReservedName(
736
+ name: string,
737
+ domain: "node" | "edge",
738
+ reserved: ReadonlySet<string>,
739
+ idColumn: Column | null,
740
+ set: number,
741
+ note: ((code: string, message: string, column?: string | null, count?: number | null) => void) | null,
742
+ ): boolean {
743
+ const low = lower(name);
744
+ const isIdName = domain === "edge" ? findColumn([name], EDGE_ID_NAMES) >= 0 : false;
745
+ if (reserved.has(low) || isIdName || (idColumn !== null && lower(idColumn.meta.name) === low)) {
746
+ note?.(
747
+ CSV_LOSS.RESERVED_NAME,
748
+ `${domain} column "${name}" is not written: the name is reserved for the ${isIdName ? "edge id" : low} column`,
749
+ name,
750
+ set,
751
+ );
752
+ return true;
753
+ }
754
+ return false;
755
+ }
756
+
757
+ /**
758
+ * Whether an attribute column with a label header name is written, with its note: not when the
759
+ * table has a label column (the name is reserved for it), else it reads back with the label role.
760
+ * @param name - the column name
761
+ * @param domain - node or edge, for messages
762
+ * @param labelColumn - the table's label role column, or null
763
+ * @param set - the column's set cells
764
+ * @param note - the recorder, or null when this table's notes are not wanted
765
+ * @returns true when the column is written
766
+ */
767
+ function labelNameWritten(
768
+ name: string,
769
+ domain: "node" | "edge",
770
+ labelColumn: Column | null,
771
+ set: number,
772
+ note: ((code: string, message: string, column?: string | null, count?: number | null) => void) | null,
773
+ ): boolean {
774
+ if (labelColumn !== null) {
775
+ note?.(
776
+ CSV_LOSS.RESERVED_NAME,
777
+ `${domain} column "${name}" is not written: the name is reserved for the label column`,
778
+ name,
779
+ set,
780
+ );
781
+ return false;
782
+ }
783
+ note?.(CSV_LOSS.ROLE_ASSUMED, `${domain} column "${name}" reads back with the label role (string)`, name, set);
784
+ return true;
785
+ }
786
+
736
787
  /**
737
788
  * Notes about a role column (id or label): a name the importer does not map to the role, and a
738
789
  * dtype other than string / dict (read back as string).
@@ -912,6 +963,22 @@ function neighbourCell(id: string, weight: string | null): string {
912
963
  return id.includes(":") ? `${id}:` : id;
913
964
  }
914
965
 
966
+ /**
967
+ * The end of the run of consecutive edge rows that share the source of row `r`.
968
+ * @param src - the edge sources by edge index
969
+ * @param edgeRows - the edges in write order
970
+ * @param r - the run's first row
971
+ * @returns the index after the run's last row
972
+ */
973
+ function sourceRunEnd(src: ArrayLike<number>, edgeRows: ArrayLike<number>, r: number): number {
974
+ const source = src[edgeRows[r]];
975
+ let end = r;
976
+ while (end < edgeRows.length && src[edgeRows[end]] === source) {
977
+ end++;
978
+ }
979
+ return end;
980
+ }
981
+
915
982
  /**
916
983
  * The rows of the adjacency table (design section 8.5: the order survives the importer's first-
917
984
  * appearance numbering). Consecutive edges with one source share a row, so the edge order is kept;
@@ -953,10 +1020,7 @@ function* adjacencyLines(snapshot: GraphSnapshot, plan: Plan): Generator<string,
953
1020
  let r = 0;
954
1021
  while (r < edgeRows.length) {
955
1022
  const source = list.src[edgeRows[r]];
956
- let end = r;
957
- while (end < edgeRows.length && list.src[edgeRows[end]] === source) {
958
- end++;
959
- }
1023
+ const end = sourceRunEnd(list.src, edgeRows, r);
960
1024
  // the nodes this row introduces, in the order the importer meets them; in index order from
961
1025
  // `next` they need nothing more
962
1026
  const fresh: number[] = [];
@@ -575,6 +575,22 @@ function isUnset(text: string, quoted: boolean | undefined): boolean {
575
575
  return quoted !== true && isBlank(text);
576
576
  }
577
577
 
578
+ /**
579
+ * The number of set neighbor cells of an adjacency row (every cell after the first).
580
+ * @param row - the cells
581
+ * @param quoted - whether each cell was quoted
582
+ * @returns the set neighbor cells
583
+ */
584
+ function setNeighborCells(row: readonly string[], quoted: readonly boolean[]): number {
585
+ let neighbors = 0;
586
+ for (let k = 1; k < row.length; k++) {
587
+ if (!isUnset(row[k], quoted[k])) {
588
+ neighbors++;
589
+ }
590
+ }
591
+ return neighbors;
592
+ }
593
+
578
594
  /** Rows between two checks of the cancellation signal on an in-memory input. */
579
595
  const ABORT_CHECK_INTERVAL = 64;
580
596
 
@@ -1520,12 +1536,7 @@ class TableReader {
1520
1536
  const { where } = this;
1521
1537
  where.line = line;
1522
1538
  where.element = null;
1523
- let neighbors = 0;
1524
- for (let k = 1; k < row.length; k++) {
1525
- if (!isUnset(row[k], quoted[k])) {
1526
- neighbors++;
1527
- }
1528
- }
1539
+ const neighbors = setNeighborCells(row, quoted);
1529
1540
  if (isUnset(row[0], quoted[0])) {
1530
1541
  report.error("missing-value", MISSING_ID_CODE, `blank node cell`, { line });
1531
1542
  counts.skippedNodes++;
@@ -410,16 +410,7 @@ export function sniffDelimiter(
410
410
  if (rows.length === 0) {
411
411
  continue;
412
412
  }
413
- let total = 0;
414
- let delta = 0;
415
- for (let i = 0; i < rows.length; i++) {
416
- const count = rows[i].length;
417
- total += count;
418
- if (i > 0) {
419
- delta += Math.abs(count - rows[i - 1].length);
420
- }
421
- }
422
- const average = total / rows.length;
413
+ const { average, delta } = rowWidths(rows);
423
414
  if (average > 1.99 && delta < bestDelta) {
424
415
  best = delimiter;
425
416
  bestDelta = delta;
@@ -428,6 +419,24 @@ export function sniffDelimiter(
428
419
  return best;
429
420
  }
430
421
 
422
+ /**
423
+ * The average width of the rows and the sum of the width changes from each row to the next.
424
+ * @param rows - the rows (at least one)
425
+ * @returns the average and the delta
426
+ */
427
+ function rowWidths(rows: readonly (readonly string[])[]): { average: number; delta: number } {
428
+ let total = 0;
429
+ let delta = 0;
430
+ for (let i = 0; i < rows.length; i++) {
431
+ const count = rows[i].length;
432
+ total += count;
433
+ if (i > 0) {
434
+ delta += Math.abs(count - rows[i - 1].length);
435
+ }
436
+ }
437
+ return { average: total / rows.length, delta };
438
+ }
439
+
431
440
  /**
432
441
  * Whether a parsed row is a blank line: one cell holding only whitespace.
433
442
  * @param row - the row
@@ -739,125 +748,169 @@ class RecordScanner {
739
748
  * @returns the position after the last character consumed
740
749
  */
741
750
  scan(chunk: string, from: number): number {
742
- const { quoteCode, delimiterCode } = this;
751
+ const { delimiterCode } = this;
743
752
  const n = chunk.length;
744
753
  let i = from;
745
754
  this.segment = from;
746
755
  while (i < n) {
747
756
  const c = chunk.charCodeAt(i);
748
- if (this.state === COMMENT) {
749
- if (c === LF || c === CR) {
750
- this.comment = this.grow(this.comment, chunk.slice(this.segment, i));
751
- this.reader.leadingComments.push(this.comment);
752
- this.reader.leadingCommentLines.push(this.line);
753
- this.comment = "";
754
- this.state = START;
755
- this.endLine(c);
756
- this.segment = i + 1;
757
- }
757
+ const { state } = this;
758
+ if (state === COMMENT) {
759
+ this.commentChar(chunk, i, c);
758
760
  i++;
759
761
  continue;
760
762
  }
761
- if (this.state === QUOTED) {
762
- // jump to the next quote; the run in between is field text whose line breaks are counted
763
- const q = chunk.indexOf(this.quoteText, i);
764
- const end = q < 0 ? n : q;
765
- if (end > i) {
766
- this.countLineBreaks(chunk, i, end);
767
- }
768
- if (q < 0) {
769
- i = n;
770
- break;
771
- }
772
- this.field = this.grow(this.field, chunk.slice(this.segment, q));
773
- this.state = CLOSING;
774
- this.lastWasCr = false;
775
- i = q + 1;
763
+ if (state === QUOTED) {
764
+ i = this.quotedRun(chunk, i);
776
765
  continue;
777
766
  }
778
767
  const isDelimiter = c === delimiterCode || (this.collapse && c === TAB);
779
- if (this.state === CLOSING) {
780
- if (c === quoteCode) {
781
- this.field = this.grow(this.field, this.quoteText);
782
- this.segment = i + 1;
783
- this.state = QUOTED;
784
- this.lastWasCr = false;
785
- i++;
786
- continue;
787
- }
788
- if (!isDelimiter && c !== LF && c !== CR) {
789
- this.reader.badQuote(this.startLine, this.count + 1);
790
- }
791
- this.state = AFTER_QUOTED;
792
- this.segment = i;
793
- // fall through to the delimiter / line-end handling below without consuming c
794
- }
795
- if (isDelimiter || c === LF || c === CR) {
796
- if (c === LF && this.lastWasCr) {
797
- // the second half of a CRLF already ended the record
798
- this.lastWasCr = false;
799
- this.segment = i + 1;
800
- i++;
801
- continue;
802
- }
803
- if (this.state === UNQUOTED) {
804
- this.field = this.grow(this.field, chunk.slice(this.segment, i));
805
- }
806
- if (isDelimiter && this.collapse && this.state === START) {
807
- // a run of whitespace, or indentation: one separator
808
- this.lastWasCr = false;
809
- this.segment = i + 1;
810
- i++;
811
- continue;
812
- }
813
- if (isDelimiter) {
814
- this.push();
815
- this.state = START;
816
- this.lastWasCr = false;
817
- this.segment = i + 1;
818
- i++;
819
- continue;
820
- }
821
- // a line ending right after a whitespace run in the whitespace dialect ends no field
822
- if (!(this.state === START && (this.count === 0 || this.collapse))) {
823
- this.push();
824
- }
825
- if (this.count > 0) {
826
- this.ready = true;
827
- this.recordLine = this.startLine;
828
- }
829
- this.state = START;
830
- this.endLine(c);
831
- this.segment = i + 1;
832
- i++;
768
+ if (state === CLOSING || isDelimiter || c === LF || c === CR) {
769
+ i = this.boundary(chunk, i, c, isDelimiter);
833
770
  if (this.ready) {
834
771
  return i;
835
772
  }
836
773
  continue;
837
774
  }
775
+ this.fieldChar(c, i);
776
+ i++;
777
+ }
778
+ return i;
779
+ }
780
+
781
+ /**
782
+ * One character of a leading comment line: a line break ends the comment.
783
+ * @param chunk - the chunk
784
+ * @param i - the character's index
785
+ * @param c - the character's code
786
+ */
787
+ private commentChar(chunk: string, i: number, c: number): void {
788
+ if (c !== LF && c !== CR) {
789
+ return;
790
+ }
791
+ this.comment = this.grow(this.comment, chunk.slice(this.segment, i));
792
+ this.reader.leadingComments.push(this.comment);
793
+ this.reader.leadingCommentLines.push(this.line);
794
+ this.comment = "";
795
+ this.state = START;
796
+ this.endLine(c);
797
+ this.segment = i + 1;
798
+ }
799
+
800
+ /**
801
+ * Inside a quoted field: jump to the next quote; the run in between is field text whose line
802
+ * breaks are counted.
803
+ * @param chunk - the chunk
804
+ * @param i - where the run starts
805
+ * @returns the position after the quote, or the chunk end when there is none
806
+ */
807
+ private quotedRun(chunk: string, i: number): number {
808
+ const q = chunk.indexOf(this.quoteText, i);
809
+ const end = q < 0 ? chunk.length : q;
810
+ if (end > i) {
811
+ this.countLineBreaks(chunk, i, end);
812
+ }
813
+ if (q < 0) {
814
+ return chunk.length;
815
+ }
816
+ this.field = this.grow(this.field, chunk.slice(this.segment, q));
817
+ this.state = CLOSING;
818
+ this.lastWasCr = false;
819
+ return q + 1;
820
+ }
821
+
822
+ /**
823
+ * The character after a quote inside a quoted field, a delimiter or a line break: a doubled
824
+ * quote, the end of a field, or the end of a record.
825
+ * @param chunk - the chunk
826
+ * @param i - the character's index
827
+ * @param c - the character's code
828
+ * @param isDelimiter - whether it separates fields
829
+ * @returns the position after the character
830
+ */
831
+ private boundary(chunk: string, i: number, c: number, isDelimiter: boolean): number {
832
+ if (this.state === CLOSING) {
833
+ if (c === this.quoteCode) {
834
+ this.field = this.grow(this.field, this.quoteText);
835
+ this.segment = i + 1;
836
+ this.state = QUOTED;
837
+ this.lastWasCr = false;
838
+ return i + 1;
839
+ }
840
+ if (!isDelimiter && c !== LF && c !== CR) {
841
+ this.reader.badQuote(this.startLine, this.count + 1);
842
+ }
843
+ this.state = AFTER_QUOTED;
844
+ this.segment = i;
845
+ }
846
+ if (c === LF && this.lastWasCr) {
847
+ // the second half of a CRLF already ended the record
838
848
  this.lastWasCr = false;
839
- if (this.state === START) {
840
- if (this.leading && this.count === 0 && this.commentCodes.includes(c)) {
841
- this.state = COMMENT;
842
- this.segment = i;
843
- i++;
844
- continue;
845
- }
846
- this.leading = false;
847
- if (c === quoteCode) {
848
- this.state = QUOTED;
849
- this.fieldQuoted = true;
850
- this.segment = i + 1;
851
- } else {
852
- this.state = UNQUOTED;
853
- this.segment = i;
854
- }
855
- } else if (c === quoteCode && this.state === UNQUOTED && this.reader.strayQuoteLine === 0) {
849
+ this.segment = i + 1;
850
+ return i + 1;
851
+ }
852
+ if (this.state === UNQUOTED) {
853
+ this.field = this.grow(this.field, chunk.slice(this.segment, i));
854
+ }
855
+ if (isDelimiter) {
856
+ // in the whitespace dialect a run of whitespace, or indentation, is one separator
857
+ if (!(this.collapse && this.state === START)) {
858
+ this.push();
859
+ this.state = START;
860
+ }
861
+ this.lastWasCr = false;
862
+ this.segment = i + 1;
863
+ return i + 1;
864
+ }
865
+ this.lineEnd(c);
866
+ this.segment = i + 1;
867
+ return i + 1;
868
+ }
869
+
870
+ /**
871
+ * A line break that ends a record (or a blank line).
872
+ * @param c - the LF or CR
873
+ */
874
+ private lineEnd(c: number): void {
875
+ // a line ending right after a whitespace run in the whitespace dialect ends no field
876
+ if (!(this.state === START && (this.count === 0 || this.collapse))) {
877
+ this.push();
878
+ }
879
+ if (this.count > 0) {
880
+ this.ready = true;
881
+ this.recordLine = this.startLine;
882
+ }
883
+ this.state = START;
884
+ this.endLine(c);
885
+ }
886
+
887
+ /**
888
+ * Any other character outside a quoted field: the start of a field or a comment, or field text.
889
+ * @param c - the character's code
890
+ * @param i - its index
891
+ */
892
+ private fieldChar(c: number, i: number): void {
893
+ this.lastWasCr = false;
894
+ if (this.state !== START) {
895
+ if (c === this.quoteCode && this.state === UNQUOTED && this.reader.strayQuoteLine === 0) {
856
896
  this.reader.strayQuoteLine = this.startLine;
857
897
  }
858
- i++;
898
+ return;
899
+ }
900
+ if (this.leading && this.count === 0 && this.commentCodes.includes(c)) {
901
+ this.state = COMMENT;
902
+ this.segment = i;
903
+ return;
904
+ }
905
+ this.leading = false;
906
+ if (c === this.quoteCode) {
907
+ this.state = QUOTED;
908
+ this.fieldQuoted = true;
909
+ this.segment = i + 1;
910
+ } else {
911
+ this.state = UNQUOTED;
912
+ this.segment = i;
859
913
  }
860
- return i;
861
914
  }
862
915
 
863
916
  /**