@graphty/graph-io 0.3.25 → 0.3.27

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 (581) hide show
  1. package/README.md +49 -414
  2. package/dist/chunks/children-CL3Cy0ez.js.map +1 -1
  3. package/dist/chunks/choose-DrXtu_96.js +56 -0
  4. package/dist/chunks/choose-DrXtu_96.js.map +1 -0
  5. package/dist/chunks/constants-ikG85dT8.js +231 -0
  6. package/dist/chunks/constants-ikG85dT8.js.map +1 -0
  7. package/dist/chunks/{escape-B-tkk_Cl.js → escape-D1HPTUrP.js} +390 -99
  8. package/dist/chunks/escape-D1HPTUrP.js.map +1 -0
  9. package/dist/chunks/export-CHd4tQE2.js +4333 -0
  10. package/dist/chunks/export-CHd4tQE2.js.map +1 -0
  11. package/dist/chunks/{importer-eUREhBGj.js → exporter-D23CKNqF.js} +4969 -3201
  12. package/dist/chunks/exporter-D23CKNqF.js.map +1 -0
  13. package/dist/chunks/{exporter-DIeGJXAZ.js → exporter-DfWTgrti.js} +23 -21
  14. package/dist/chunks/exporter-DfWTgrti.js.map +1 -0
  15. package/dist/chunks/exporter-DhtW6FxK.js +3465 -0
  16. package/dist/chunks/exporter-DhtW6FxK.js.map +1 -0
  17. package/dist/chunks/{exporter-BGrsWamJ.js → exporter-IHv4nkCm.js} +38 -229
  18. package/dist/chunks/exporter-IHv4nkCm.js.map +1 -0
  19. package/dist/chunks/{importer-BWY2FFc5.js → importer-B1wmPQjr.js} +255 -77
  20. package/dist/chunks/importer-B1wmPQjr.js.map +1 -0
  21. package/dist/chunks/{importer-D_e7e7LX.js → importer-B2W_58pz.js} +399 -146
  22. package/dist/chunks/importer-B2W_58pz.js.map +1 -0
  23. package/dist/chunks/importer-BSALvkui.js +2811 -0
  24. package/dist/chunks/importer-BSALvkui.js.map +1 -0
  25. package/dist/chunks/{importer-vi3bSdtw.js → importer-BVRE--Un.js} +510 -77
  26. package/dist/chunks/importer-BVRE--Un.js.map +1 -0
  27. package/dist/chunks/{importer-DET1tg6s.js → importer-CIAzt-wo.js} +1689 -312
  28. package/dist/chunks/importer-CIAzt-wo.js.map +1 -0
  29. package/dist/chunks/{importer-DIrbbnAf.js → importer-DpEW5zeG.js} +585 -226
  30. package/dist/chunks/importer-DpEW5zeG.js.map +1 -0
  31. package/dist/chunks/{importer-zGLo8Dg_.js → importer-z_FL4VMb.js} +488 -161
  32. package/dist/chunks/importer-z_FL4VMb.js.map +1 -0
  33. package/dist/chunks/json-elements-BAmNBZok.js +1225 -0
  34. package/dist/chunks/json-elements-BAmNBZok.js.map +1 -0
  35. package/dist/chunks/ontology-export-Bab4eUA8.js +357 -0
  36. package/dist/chunks/ontology-export-Bab4eUA8.js.map +1 -0
  37. package/dist/chunks/{records-dbRkxwaq.js → records-CdlvL4RO.js} +298 -114
  38. package/dist/chunks/records-CdlvL4RO.js.map +1 -0
  39. package/dist/chunks/{text-Dr0Ifpag.js → text-DzXjAiTl.js} +29 -11
  40. package/dist/chunks/text-DzXjAiTl.js.map +1 -0
  41. package/dist/chunks/{writer-C7flM-Ih.js → writer-DxjgHwSe.js} +19 -1
  42. package/dist/chunks/writer-DxjgHwSe.js.map +1 -0
  43. package/dist/csv.js +952 -262
  44. package/dist/csv.js.map +1 -1
  45. package/dist/cx.js +6 -3
  46. package/dist/cx2.js +2 -2
  47. package/dist/cys.js +6 -3
  48. package/dist/dot.js +1 -1
  49. package/dist/gexf.js +554 -122
  50. package/dist/gexf.js.map +1 -1
  51. package/dist/gml.js +344 -87
  52. package/dist/gml.js.map +1 -1
  53. package/dist/graph-io.js +1105 -213
  54. package/dist/graph-io.js.map +1 -1
  55. package/dist/graphml.js +3 -2
  56. package/dist/graphml.js.map +1 -1
  57. package/dist/json.js +3 -2
  58. package/dist/neo4j.js +565 -158
  59. package/dist/neo4j.js.map +1 -1
  60. package/dist/obo.js +6 -3
  61. package/dist/pajek.js +1 -1
  62. package/dist/src/children.d.ts +18 -6
  63. package/dist/src/children.d.ts.map +1 -1
  64. package/dist/src/children.js +6 -3
  65. package/dist/src/children.js.map +1 -1
  66. package/dist/src/common/attributes.d.ts +29 -11
  67. package/dist/src/common/attributes.d.ts.map +1 -1
  68. package/dist/src/common/attributes.js +25 -7
  69. package/dist/src/common/attributes.js.map +1 -1
  70. package/dist/src/common/cell-budget.d.ts +9 -2
  71. package/dist/src/common/cell-budget.d.ts.map +1 -1
  72. package/dist/src/common/cell-budget.js +11 -3
  73. package/dist/src/common/cell-budget.js.map +1 -1
  74. package/dist/src/common/choose.d.ts +19 -0
  75. package/dist/src/common/choose.d.ts.map +1 -0
  76. package/dist/src/common/choose.js +72 -0
  77. package/dist/src/common/choose.js.map +1 -0
  78. package/dist/src/common/codes.d.ts +456 -66
  79. package/dist/src/common/codes.d.ts.map +1 -1
  80. package/dist/src/common/codes.js +458 -66
  81. package/dist/src/common/codes.js.map +1 -1
  82. package/dist/src/common/compare.d.ts +81 -0
  83. package/dist/src/common/compare.d.ts.map +1 -0
  84. package/dist/src/common/compare.js +394 -0
  85. package/dist/src/common/compare.js.map +1 -0
  86. package/dist/src/common/cx-export.d.ts +123 -0
  87. package/dist/src/common/cx-export.d.ts.map +1 -0
  88. package/dist/src/common/cx-export.js +334 -0
  89. package/dist/src/common/cx-export.js.map +1 -0
  90. package/dist/src/common/declared-types.d.ts +22 -2
  91. package/dist/src/common/declared-types.d.ts.map +1 -1
  92. package/dist/src/common/declared-types.js +21 -3
  93. package/dist/src/common/declared-types.js.map +1 -1
  94. package/dist/src/common/direction.d.ts +74 -50
  95. package/dist/src/common/direction.d.ts.map +1 -1
  96. package/dist/src/common/direction.js +47 -27
  97. package/dist/src/common/direction.js.map +1 -1
  98. package/dist/src/common/escape.d.ts +21 -9
  99. package/dist/src/common/escape.d.ts.map +1 -1
  100. package/dist/src/common/escape.js +39 -22
  101. package/dist/src/common/escape.js.map +1 -1
  102. package/dist/src/common/export.d.ts +144 -32
  103. package/dist/src/common/export.d.ts.map +1 -1
  104. package/dist/src/common/export.js +216 -42
  105. package/dist/src/common/export.js.map +1 -1
  106. package/dist/src/common/format.d.ts +23 -1
  107. package/dist/src/common/format.d.ts.map +1 -1
  108. package/dist/src/common/format.js +26 -1
  109. package/dist/src/common/format.js.map +1 -1
  110. package/dist/src/common/ids.d.ts +9 -2
  111. package/dist/src/common/ids.d.ts.map +1 -1
  112. package/dist/src/common/ids.js +6 -2
  113. package/dist/src/common/ids.js.map +1 -1
  114. package/dist/src/common/input.d.ts +122 -28
  115. package/dist/src/common/input.d.ts.map +1 -1
  116. package/dist/src/common/input.js +890 -124
  117. package/dist/src/common/input.js.map +1 -1
  118. package/dist/src/common/json-elements.d.ts +144 -20
  119. package/dist/src/common/json-elements.d.ts.map +1 -1
  120. package/dist/src/common/json-elements.js +869 -189
  121. package/dist/src/common/json-elements.js.map +1 -1
  122. package/dist/src/common/line-format.d.ts +90 -0
  123. package/dist/src/common/line-format.d.ts.map +1 -0
  124. package/dist/src/common/line-format.js +185 -0
  125. package/dist/src/common/line-format.js.map +1 -0
  126. package/dist/src/common/lists.d.ts +6 -1
  127. package/dist/src/common/lists.d.ts.map +1 -1
  128. package/dist/src/common/lists.js +3 -1
  129. package/dist/src/common/lists.js.map +1 -1
  130. package/dist/src/common/ontology-export.d.ts +167 -0
  131. package/dist/src/common/ontology-export.d.ts.map +1 -0
  132. package/dist/src/common/ontology-export.js +359 -0
  133. package/dist/src/common/ontology-export.js.map +1 -0
  134. package/dist/src/common/ontology.d.ts +25 -3
  135. package/dist/src/common/ontology.d.ts.map +1 -1
  136. package/dist/src/common/ontology.js +30 -9
  137. package/dist/src/common/ontology.js.map +1 -1
  138. package/dist/src/common/options.d.ts +62 -12
  139. package/dist/src/common/options.d.ts.map +1 -1
  140. package/dist/src/common/options.js +89 -11
  141. package/dist/src/common/options.js.map +1 -1
  142. package/dist/src/common/plural.d.ts +31 -0
  143. package/dist/src/common/plural.d.ts.map +1 -0
  144. package/dist/src/common/plural.js +46 -0
  145. package/dist/src/common/plural.js.map +1 -0
  146. package/dist/src/common/report.d.ts +72 -14
  147. package/dist/src/common/report.d.ts.map +1 -1
  148. package/dist/src/common/report.js +146 -22
  149. package/dist/src/common/report.js.map +1 -1
  150. package/dist/src/common/temporal.d.ts +22 -5
  151. package/dist/src/common/temporal.d.ts.map +1 -1
  152. package/dist/src/common/temporal.js +13 -2
  153. package/dist/src/common/temporal.js.map +1 -1
  154. package/dist/src/common/text.d.ts +39 -23
  155. package/dist/src/common/text.d.ts.map +1 -1
  156. package/dist/src/common/text.js +51 -21
  157. package/dist/src/common/text.js.map +1 -1
  158. package/dist/src/common/weights.d.ts +37 -4
  159. package/dist/src/common/weights.d.ts.map +1 -1
  160. package/dist/src/common/weights.js +61 -2
  161. package/dist/src/common/weights.js.map +1 -1
  162. package/dist/src/common/writer.d.ts +19 -3
  163. package/dist/src/common/writer.d.ts.map +1 -1
  164. package/dist/src/common/writer.js +30 -2
  165. package/dist/src/common/writer.js.map +1 -1
  166. package/dist/src/common/xml.d.ts +125 -9
  167. package/dist/src/common/xml.d.ts.map +1 -1
  168. package/dist/src/common/xml.js +402 -85
  169. package/dist/src/common/xml.js.map +1 -1
  170. package/dist/src/common/zip.d.ts +44 -4
  171. package/dist/src/common/zip.d.ts.map +1 -1
  172. package/dist/src/common/zip.js +134 -5
  173. package/dist/src/common/zip.js.map +1 -1
  174. package/dist/src/formats/csv/exporter.d.ts +80 -24
  175. package/dist/src/formats/csv/exporter.d.ts.map +1 -1
  176. package/dist/src/formats/csv/exporter.js +146 -33
  177. package/dist/src/formats/csv/exporter.js.map +1 -1
  178. package/dist/src/formats/csv/header.d.ts +38 -9
  179. package/dist/src/formats/csv/header.d.ts.map +1 -1
  180. package/dist/src/formats/csv/header.js +46 -10
  181. package/dist/src/formats/csv/header.js.map +1 -1
  182. package/dist/src/formats/csv/importer.d.ts +187 -37
  183. package/dist/src/formats/csv/importer.d.ts.map +1 -1
  184. package/dist/src/formats/csv/importer.js +835 -204
  185. package/dist/src/formats/csv/importer.js.map +1 -1
  186. package/dist/src/formats/csv/index.d.ts +100 -19
  187. package/dist/src/formats/csv/index.d.ts.map +1 -1
  188. package/dist/src/formats/csv/index.js +94 -21
  189. package/dist/src/formats/csv/index.js.map +1 -1
  190. package/dist/src/formats/csv/records.d.ts +95 -10
  191. package/dist/src/formats/csv/records.d.ts.map +1 -1
  192. package/dist/src/formats/csv/records.js +333 -122
  193. package/dist/src/formats/csv/records.js.map +1 -1
  194. package/dist/src/formats/csv/values.d.ts +1 -0
  195. package/dist/src/formats/csv/values.d.ts.map +1 -1
  196. package/dist/src/formats/csv/values.js +1 -0
  197. package/dist/src/formats/csv/values.js.map +1 -1
  198. package/dist/src/formats/cx/exporter.d.ts +102 -0
  199. package/dist/src/formats/cx/exporter.d.ts.map +1 -0
  200. package/dist/src/formats/cx/exporter.js +1217 -0
  201. package/dist/src/formats/cx/exporter.js.map +1 -0
  202. package/dist/src/formats/cx/importer.d.ts +79 -32
  203. package/dist/src/formats/cx/importer.d.ts.map +1 -1
  204. package/dist/src/formats/cx/importer.js +682 -287
  205. package/dist/src/formats/cx/importer.js.map +1 -1
  206. package/dist/src/formats/cx/index.d.ts +4 -3
  207. package/dist/src/formats/cx/index.d.ts.map +1 -1
  208. package/dist/src/formats/cx/index.js +4 -3
  209. package/dist/src/formats/cx/index.js.map +1 -1
  210. package/dist/src/formats/cx2/exporter.d.ts +29 -11
  211. package/dist/src/formats/cx2/exporter.d.ts.map +1 -1
  212. package/dist/src/formats/cx2/exporter.js +39 -268
  213. package/dist/src/formats/cx2/exporter.js.map +1 -1
  214. package/dist/src/formats/cx2/importer.d.ts +78 -28
  215. package/dist/src/formats/cx2/importer.d.ts.map +1 -1
  216. package/dist/src/formats/cx2/importer.js +227 -80
  217. package/dist/src/formats/cx2/importer.js.map +1 -1
  218. package/dist/src/formats/cx2/index.d.ts +2 -1
  219. package/dist/src/formats/cx2/index.d.ts.map +1 -1
  220. package/dist/src/formats/cx2/index.js +2 -1
  221. package/dist/src/formats/cx2/index.js.map +1 -1
  222. package/dist/src/formats/cys/constants.d.ts +66 -17
  223. package/dist/src/formats/cys/constants.d.ts.map +1 -1
  224. package/dist/src/formats/cys/constants.js +59 -18
  225. package/dist/src/formats/cys/constants.js.map +1 -1
  226. package/dist/src/formats/cys/exporter.d.ts +118 -0
  227. package/dist/src/formats/cys/exporter.d.ts.map +1 -0
  228. package/dist/src/formats/cys/exporter.js +1072 -0
  229. package/dist/src/formats/cys/exporter.js.map +1 -0
  230. package/dist/src/formats/cys/importer.d.ts +22 -8
  231. package/dist/src/formats/cys/importer.d.ts.map +1 -1
  232. package/dist/src/formats/cys/importer.js +510 -149
  233. package/dist/src/formats/cys/importer.js.map +1 -1
  234. package/dist/src/formats/cys/index.d.ts +4 -2
  235. package/dist/src/formats/cys/index.d.ts.map +1 -1
  236. package/dist/src/formats/cys/index.js +4 -2
  237. package/dist/src/formats/cys/index.js.map +1 -1
  238. package/dist/src/formats/cys/session.d.ts +46 -7
  239. package/dist/src/formats/cys/session.d.ts.map +1 -1
  240. package/dist/src/formats/cys/session.js +101 -27
  241. package/dist/src/formats/cys/session.js.map +1 -1
  242. package/dist/src/formats/cys/tables.d.ts +9 -2
  243. package/dist/src/formats/cys/tables.d.ts.map +1 -1
  244. package/dist/src/formats/cys/tables.js +29 -7
  245. package/dist/src/formats/cys/tables.js.map +1 -1
  246. package/dist/src/formats/dot/exporter.d.ts +42 -14
  247. package/dist/src/formats/dot/exporter.d.ts.map +1 -1
  248. package/dist/src/formats/dot/exporter.js +56 -29
  249. package/dist/src/formats/dot/exporter.js.map +1 -1
  250. package/dist/src/formats/dot/importer.d.ts +91 -29
  251. package/dist/src/formats/dot/importer.d.ts.map +1 -1
  252. package/dist/src/formats/dot/importer.js +190 -39
  253. package/dist/src/formats/dot/importer.js.map +1 -1
  254. package/dist/src/formats/dot/index.d.ts +2 -1
  255. package/dist/src/formats/dot/index.d.ts.map +1 -1
  256. package/dist/src/formats/dot/index.js +2 -1
  257. package/dist/src/formats/dot/index.js.map +1 -1
  258. package/dist/src/formats/dot/names.d.ts +44 -11
  259. package/dist/src/formats/dot/names.d.ts.map +1 -1
  260. package/dist/src/formats/dot/names.js +44 -11
  261. package/dist/src/formats/dot/names.js.map +1 -1
  262. package/dist/src/formats/dot/tokenizer.d.ts +9 -2
  263. package/dist/src/formats/dot/tokenizer.d.ts.map +1 -1
  264. package/dist/src/formats/dot/tokenizer.js +20 -5
  265. package/dist/src/formats/dot/tokenizer.js.map +1 -1
  266. package/dist/src/formats/gexf/exporter.d.ts +46 -15
  267. package/dist/src/formats/gexf/exporter.d.ts.map +1 -1
  268. package/dist/src/formats/gexf/exporter.js +45 -15
  269. package/dist/src/formats/gexf/exporter.js.map +1 -1
  270. package/dist/src/formats/gexf/importer.d.ts +87 -23
  271. package/dist/src/formats/gexf/importer.d.ts.map +1 -1
  272. package/dist/src/formats/gexf/importer.js +471 -90
  273. package/dist/src/formats/gexf/importer.js.map +1 -1
  274. package/dist/src/formats/gexf/index.d.ts +84 -21
  275. package/dist/src/formats/gexf/index.d.ts.map +1 -1
  276. package/dist/src/formats/gexf/index.js +80 -23
  277. package/dist/src/formats/gexf/index.js.map +1 -1
  278. package/dist/src/formats/gexf/schema.d.ts +67 -15
  279. package/dist/src/formats/gexf/schema.d.ts.map +1 -1
  280. package/dist/src/formats/gexf/schema.js +59 -13
  281. package/dist/src/formats/gexf/schema.js.map +1 -1
  282. package/dist/src/formats/gml/exporter.d.ts +69 -21
  283. package/dist/src/formats/gml/exporter.d.ts.map +1 -1
  284. package/dist/src/formats/gml/exporter.js +74 -29
  285. package/dist/src/formats/gml/exporter.js.map +1 -1
  286. package/dist/src/formats/gml/importer.d.ts +116 -25
  287. package/dist/src/formats/gml/importer.d.ts.map +1 -1
  288. package/dist/src/formats/gml/importer.js +290 -61
  289. package/dist/src/formats/gml/importer.js.map +1 -1
  290. package/dist/src/formats/gml/index.d.ts +87 -24
  291. package/dist/src/formats/gml/index.d.ts.map +1 -1
  292. package/dist/src/formats/gml/index.js +79 -26
  293. package/dist/src/formats/gml/index.js.map +1 -1
  294. package/dist/src/formats/gml/syntax.d.ts +67 -18
  295. package/dist/src/formats/gml/syntax.d.ts.map +1 -1
  296. package/dist/src/formats/gml/syntax.js +110 -28
  297. package/dist/src/formats/gml/syntax.js.map +1 -1
  298. package/dist/src/formats/graphml/constants.d.ts +174 -44
  299. package/dist/src/formats/graphml/constants.d.ts.map +1 -1
  300. package/dist/src/formats/graphml/constants.js +165 -44
  301. package/dist/src/formats/graphml/constants.js.map +1 -1
  302. package/dist/src/formats/graphml/exporter.d.ts +17 -9
  303. package/dist/src/formats/graphml/exporter.d.ts.map +1 -1
  304. package/dist/src/formats/graphml/exporter.js +33 -12
  305. package/dist/src/formats/graphml/exporter.js.map +1 -1
  306. package/dist/src/formats/graphml/importer.d.ts +19 -7
  307. package/dist/src/formats/graphml/importer.d.ts.map +1 -1
  308. package/dist/src/formats/graphml/importer.js +466 -39
  309. package/dist/src/formats/graphml/importer.js.map +1 -1
  310. package/dist/src/formats/graphml/index.d.ts +2 -1
  311. package/dist/src/formats/graphml/index.d.ts.map +1 -1
  312. package/dist/src/formats/graphml/index.js +2 -1
  313. package/dist/src/formats/graphml/index.js.map +1 -1
  314. package/dist/src/formats/graphml/tree.d.ts +6 -2
  315. package/dist/src/formats/graphml/tree.d.ts.map +1 -1
  316. package/dist/src/formats/graphml/tree.js +6 -2
  317. package/dist/src/formats/graphml/tree.js.map +1 -1
  318. package/dist/src/formats/graphml/yfiles.d.ts +8 -3
  319. package/dist/src/formats/graphml/yfiles.d.ts.map +1 -1
  320. package/dist/src/formats/graphml/yfiles.js +36 -15
  321. package/dist/src/formats/graphml/yfiles.js.map +1 -1
  322. package/dist/src/formats/json/dialect.d.ts +88 -24
  323. package/dist/src/formats/json/dialect.d.ts.map +1 -1
  324. package/dist/src/formats/json/dialect.js +109 -19
  325. package/dist/src/formats/json/dialect.js.map +1 -1
  326. package/dist/src/formats/json/exporter.d.ts +134 -22
  327. package/dist/src/formats/json/exporter.d.ts.map +1 -1
  328. package/dist/src/formats/json/exporter.js +135 -54
  329. package/dist/src/formats/json/exporter.js.map +1 -1
  330. package/dist/src/formats/json/importer.d.ts +173 -52
  331. package/dist/src/formats/json/importer.d.ts.map +1 -1
  332. package/dist/src/formats/json/importer.js +899 -220
  333. package/dist/src/formats/json/importer.js.map +1 -1
  334. package/dist/src/formats/json/index.d.ts +3 -2
  335. package/dist/src/formats/json/index.d.ts.map +1 -1
  336. package/dist/src/formats/json/index.js +3 -2
  337. package/dist/src/formats/json/index.js.map +1 -1
  338. package/dist/src/formats/json/obographs-export.d.ts +68 -0
  339. package/dist/src/formats/json/obographs-export.d.ts.map +1 -0
  340. package/dist/src/formats/json/obographs-export.js +941 -0
  341. package/dist/src/formats/json/obographs-export.js.map +1 -0
  342. package/dist/src/formats/json/obographs.d.ts +26 -0
  343. package/dist/src/formats/json/obographs.d.ts.map +1 -1
  344. package/dist/src/formats/json/obographs.js +297 -80
  345. package/dist/src/formats/json/obographs.js.map +1 -1
  346. package/dist/src/formats/neo4j/exporter.d.ts +109 -21
  347. package/dist/src/formats/neo4j/exporter.d.ts.map +1 -1
  348. package/dist/src/formats/neo4j/exporter.js +239 -86
  349. package/dist/src/formats/neo4j/exporter.js.map +1 -1
  350. package/dist/src/formats/neo4j/header.d.ts +17 -4
  351. package/dist/src/formats/neo4j/header.d.ts.map +1 -1
  352. package/dist/src/formats/neo4j/header.js +36 -4
  353. package/dist/src/formats/neo4j/header.js.map +1 -1
  354. package/dist/src/formats/neo4j/importer.d.ts +107 -27
  355. package/dist/src/formats/neo4j/importer.d.ts.map +1 -1
  356. package/dist/src/formats/neo4j/importer.js +415 -63
  357. package/dist/src/formats/neo4j/importer.js.map +1 -1
  358. package/dist/src/formats/neo4j/index.d.ts +73 -23
  359. package/dist/src/formats/neo4j/index.d.ts.map +1 -1
  360. package/dist/src/formats/neo4j/index.js +65 -25
  361. package/dist/src/formats/neo4j/index.js.map +1 -1
  362. package/dist/src/formats/obo/exporter.d.ts +150 -0
  363. package/dist/src/formats/obo/exporter.d.ts.map +1 -0
  364. package/dist/src/formats/obo/exporter.js +1736 -0
  365. package/dist/src/formats/obo/exporter.js.map +1 -0
  366. package/dist/src/formats/obo/importer.d.ts +95 -19
  367. package/dist/src/formats/obo/importer.d.ts.map +1 -1
  368. package/dist/src/formats/obo/importer.js +475 -104
  369. package/dist/src/formats/obo/importer.js.map +1 -1
  370. package/dist/src/formats/obo/index.d.ts +6 -3
  371. package/dist/src/formats/obo/index.d.ts.map +1 -1
  372. package/dist/src/formats/obo/index.js +6 -3
  373. package/dist/src/formats/obo/index.js.map +1 -1
  374. package/dist/src/formats/obo/syntax.d.ts +108 -4
  375. package/dist/src/formats/obo/syntax.d.ts.map +1 -1
  376. package/dist/src/formats/obo/syntax.js +211 -34
  377. package/dist/src/formats/obo/syntax.js.map +1 -1
  378. package/dist/src/formats/pajek/exporter.d.ts +54 -16
  379. package/dist/src/formats/pajek/exporter.d.ts.map +1 -1
  380. package/dist/src/formats/pajek/exporter.js +67 -35
  381. package/dist/src/formats/pajek/exporter.js.map +1 -1
  382. package/dist/src/formats/pajek/importer.d.ts +84 -22
  383. package/dist/src/formats/pajek/importer.d.ts.map +1 -1
  384. package/dist/src/formats/pajek/importer.js +303 -83
  385. package/dist/src/formats/pajek/importer.js.map +1 -1
  386. package/dist/src/formats/pajek/index.d.ts +2 -1
  387. package/dist/src/formats/pajek/index.d.ts.map +1 -1
  388. package/dist/src/formats/pajek/index.js +2 -1
  389. package/dist/src/formats/pajek/index.js.map +1 -1
  390. package/dist/src/formats/pajek/syntax.d.ts +77 -14
  391. package/dist/src/formats/pajek/syntax.d.ts.map +1 -1
  392. package/dist/src/formats/pajek/syntax.js +186 -54
  393. package/dist/src/formats/pajek/syntax.js.map +1 -1
  394. package/dist/src/formats/xgmml/columns.d.ts +1 -0
  395. package/dist/src/formats/xgmml/columns.d.ts.map +1 -1
  396. package/dist/src/formats/xgmml/columns.js +4 -0
  397. package/dist/src/formats/xgmml/columns.js.map +1 -1
  398. package/dist/src/formats/xgmml/constants.d.ts +186 -56
  399. package/dist/src/formats/xgmml/constants.d.ts.map +1 -1
  400. package/dist/src/formats/xgmml/constants.js +181 -57
  401. package/dist/src/formats/xgmml/constants.js.map +1 -1
  402. package/dist/src/formats/xgmml/document.d.ts +65 -11
  403. package/dist/src/formats/xgmml/document.d.ts.map +1 -1
  404. package/dist/src/formats/xgmml/document.js +157 -28
  405. package/dist/src/formats/xgmml/document.js.map +1 -1
  406. package/dist/src/formats/xgmml/emit.d.ts +61 -3
  407. package/dist/src/formats/xgmml/emit.d.ts.map +1 -1
  408. package/dist/src/formats/xgmml/emit.js +129 -26
  409. package/dist/src/formats/xgmml/emit.js.map +1 -1
  410. package/dist/src/formats/xgmml/exporter.d.ts +13 -6
  411. package/dist/src/formats/xgmml/exporter.d.ts.map +1 -1
  412. package/dist/src/formats/xgmml/exporter.js +21 -22
  413. package/dist/src/formats/xgmml/exporter.js.map +1 -1
  414. package/dist/src/formats/xgmml/importer.d.ts +33 -12
  415. package/dist/src/formats/xgmml/importer.d.ts.map +1 -1
  416. package/dist/src/formats/xgmml/importer.js +35 -72
  417. package/dist/src/formats/xgmml/importer.js.map +1 -1
  418. package/dist/src/formats/xgmml/index.d.ts +1 -0
  419. package/dist/src/formats/xgmml/index.d.ts.map +1 -1
  420. package/dist/src/formats/xgmml/index.js +1 -0
  421. package/dist/src/formats/xgmml/index.js.map +1 -1
  422. package/dist/src/formats/xgmml/values.d.ts +8 -1
  423. package/dist/src/formats/xgmml/values.d.ts.map +1 -1
  424. package/dist/src/formats/xgmml/values.js +8 -3
  425. package/dist/src/formats/xgmml/values.js.map +1 -1
  426. package/dist/src/index.d.ts +23 -18
  427. package/dist/src/index.d.ts.map +1 -1
  428. package/dist/src/index.js +28 -16
  429. package/dist/src/index.js.map +1 -1
  430. package/dist/src/registry.d.ts +478 -92
  431. package/dist/src/registry.d.ts.map +1 -1
  432. package/dist/src/registry.js +807 -88
  433. package/dist/src/registry.js.map +1 -1
  434. package/dist/src/sniff.d.ts +61 -19
  435. package/dist/src/sniff.d.ts.map +1 -1
  436. package/dist/src/sniff.js +121 -29
  437. package/dist/src/sniff.js.map +1 -1
  438. package/dist/src/types.d.ts +362 -112
  439. package/dist/src/types.d.ts.map +1 -1
  440. package/dist/src/types.js +28 -7
  441. package/dist/src/types.js.map +1 -1
  442. package/dist/xgmml.js +2 -2
  443. package/docs/samples/README.md +25 -0
  444. package/docs/samples/got-edges.csv +353 -0
  445. package/docs/samples/got-network.graphml +1384 -0
  446. package/docs/samples/got-nodes.csv +108 -0
  447. package/docs/samples/got.cx +930 -0
  448. package/docs/samples/got.cx2 +465 -0
  449. package/docs/samples/got.gexf +471 -0
  450. package/docs/samples/got.gml +2650 -0
  451. package/docs/samples/got.json +2546 -0
  452. package/docs/samples/got.net +461 -0
  453. package/docs/samples/movies-nodes.csv +9 -0
  454. package/docs/samples/movies-rels.csv +8 -0
  455. package/docs/samples/networks.cys +0 -0
  456. package/docs/samples/proteins.xgmml +9 -0
  457. package/docs/samples/teams.gv +24 -0
  458. package/docs/samples/vehicles.obo +25 -0
  459. package/package.json +9 -4
  460. package/src/children.ts +18 -6
  461. package/src/common/attributes.ts +29 -11
  462. package/src/common/cell-budget.ts +11 -3
  463. package/src/common/choose.ts +89 -0
  464. package/src/common/codes.ts +490 -66
  465. package/src/common/compare.ts +472 -0
  466. package/src/common/cx-export.ts +432 -0
  467. package/src/common/declared-types.ts +40 -6
  468. package/src/common/direction.ts +78 -51
  469. package/src/common/escape.ts +39 -22
  470. package/src/common/export.ts +284 -47
  471. package/src/common/format.ts +30 -1
  472. package/src/common/ids.ts +14 -3
  473. package/src/common/input.ts +1057 -134
  474. package/src/common/json-elements.ts +1035 -199
  475. package/src/common/line-format.ts +249 -0
  476. package/src/common/lists.ts +7 -2
  477. package/src/common/ontology-export.ts +461 -0
  478. package/src/common/ontology.ts +31 -9
  479. package/src/common/options.ts +110 -15
  480. package/src/common/plural.ts +49 -0
  481. package/src/common/report.ts +168 -25
  482. package/src/common/temporal.ts +22 -5
  483. package/src/common/text.ts +62 -23
  484. package/src/common/weights.ts +74 -4
  485. package/src/common/writer.ts +37 -3
  486. package/src/common/xml.ts +479 -90
  487. package/src/common/zip.ts +169 -7
  488. package/src/formats/csv/exporter.ts +202 -47
  489. package/src/formats/csv/header.ts +50 -11
  490. package/src/formats/csv/importer.ts +1133 -245
  491. package/src/formats/csv/index.ts +106 -19
  492. package/src/formats/csv/records.ts +396 -122
  493. package/src/formats/csv/values.ts +1 -0
  494. package/src/formats/cx/exporter.ts +1483 -0
  495. package/src/formats/cx/importer.ts +890 -302
  496. package/src/formats/cx/index.ts +4 -3
  497. package/src/formats/cx2/exporter.ts +62 -339
  498. package/src/formats/cx2/importer.ts +318 -87
  499. package/src/formats/cx2/index.ts +2 -1
  500. package/src/formats/cys/constants.ts +62 -17
  501. package/src/formats/cys/exporter.ts +1368 -0
  502. package/src/formats/cys/importer.ts +659 -157
  503. package/src/formats/cys/index.ts +4 -2
  504. package/src/formats/cys/session.ts +142 -34
  505. package/src/formats/cys/tables.ts +47 -9
  506. package/src/formats/dot/exporter.ts +83 -37
  507. package/src/formats/dot/importer.ts +248 -54
  508. package/src/formats/dot/index.ts +2 -1
  509. package/src/formats/dot/names.ts +44 -11
  510. package/src/formats/dot/tokenizer.ts +25 -6
  511. package/src/formats/gexf/exporter.ts +57 -18
  512. package/src/formats/gexf/importer.ts +636 -105
  513. package/src/formats/gexf/index.ts +90 -21
  514. package/src/formats/gexf/schema.ts +67 -15
  515. package/src/formats/gml/exporter.ts +90 -36
  516. package/src/formats/gml/importer.ts +388 -67
  517. package/src/formats/gml/index.ts +85 -24
  518. package/src/formats/gml/syntax.ts +113 -28
  519. package/src/formats/graphml/constants.ts +178 -44
  520. package/src/formats/graphml/exporter.ts +52 -18
  521. package/src/formats/graphml/importer.ts +640 -60
  522. package/src/formats/graphml/index.ts +2 -1
  523. package/src/formats/graphml/tree.ts +6 -2
  524. package/src/formats/graphml/yfiles.ts +37 -15
  525. package/src/formats/json/dialect.ts +128 -29
  526. package/src/formats/json/exporter.ts +205 -68
  527. package/src/formats/json/importer.ts +1163 -253
  528. package/src/formats/json/index.ts +3 -1
  529. package/src/formats/json/obographs-export.ts +1197 -0
  530. package/src/formats/json/obographs.ts +353 -92
  531. package/src/formats/neo4j/exporter.ts +321 -102
  532. package/src/formats/neo4j/header.ts +44 -6
  533. package/src/formats/neo4j/importer.ts +512 -73
  534. package/src/formats/neo4j/index.ts +75 -23
  535. package/src/formats/obo/exporter.ts +2210 -0
  536. package/src/formats/obo/importer.ts +622 -109
  537. package/src/formats/obo/index.ts +6 -3
  538. package/src/formats/obo/syntax.ts +232 -37
  539. package/src/formats/pajek/exporter.ts +90 -42
  540. package/src/formats/pajek/importer.ts +407 -93
  541. package/src/formats/pajek/index.ts +2 -1
  542. package/src/formats/pajek/syntax.ts +216 -55
  543. package/src/formats/xgmml/columns.ts +10 -0
  544. package/src/formats/xgmml/constants.ts +183 -56
  545. package/src/formats/xgmml/document.ts +212 -34
  546. package/src/formats/xgmml/emit.ts +175 -34
  547. package/src/formats/xgmml/exporter.ts +36 -28
  548. package/src/formats/xgmml/importer.ts +64 -86
  549. package/src/formats/xgmml/index.ts +1 -0
  550. package/src/formats/xgmml/values.ts +12 -4
  551. package/src/index.ts +81 -11
  552. package/src/registry.ts +1121 -135
  553. package/src/sniff.ts +163 -32
  554. package/src/types.ts +385 -114
  555. package/dist/chunks/escape-B-tkk_Cl.js.map +0 -1
  556. package/dist/chunks/export-Bh60Dv-n.js +0 -582
  557. package/dist/chunks/export-Bh60Dv-n.js.map +0 -1
  558. package/dist/chunks/exporter-BGrsWamJ.js.map +0 -1
  559. package/dist/chunks/exporter-DIeGJXAZ.js.map +0 -1
  560. package/dist/chunks/importer-BWY2FFc5.js.map +0 -1
  561. package/dist/chunks/importer-DD-xv9_Y.js +0 -1471
  562. package/dist/chunks/importer-DD-xv9_Y.js.map +0 -1
  563. package/dist/chunks/importer-DET1tg6s.js.map +0 -1
  564. package/dist/chunks/importer-DEcKhsmQ.js +0 -1614
  565. package/dist/chunks/importer-DEcKhsmQ.js.map +0 -1
  566. package/dist/chunks/importer-DIrbbnAf.js.map +0 -1
  567. package/dist/chunks/importer-D_e7e7LX.js.map +0 -1
  568. package/dist/chunks/importer-eUREhBGj.js.map +0 -1
  569. package/dist/chunks/importer-vi3bSdtw.js.map +0 -1
  570. package/dist/chunks/importer-zGLo8Dg_.js.map +0 -1
  571. package/dist/chunks/json-elements-DDS8N2Dd.js +0 -779
  572. package/dist/chunks/json-elements-DDS8N2Dd.js.map +0 -1
  573. package/dist/chunks/ontology-BnrJ4I98.js +0 -113
  574. package/dist/chunks/ontology-BnrJ4I98.js.map +0 -1
  575. package/dist/chunks/records-dbRkxwaq.js.map +0 -1
  576. package/dist/chunks/report-B1z4WT9e.js +0 -2372
  577. package/dist/chunks/report-B1z4WT9e.js.map +0 -1
  578. package/dist/chunks/text-Dr0Ifpag.js.map +0 -1
  579. package/dist/chunks/weights-Dzba96G3.js +0 -176
  580. package/dist/chunks/weights-Dzba96G3.js.map +0 -1
  581. package/dist/chunks/writer-C7flM-Ih.js.map +0 -1
@@ -24,37 +24,67 @@ import { declareResolved, uniqueColumnName } from "./attributes.js";
24
24
  import {
25
25
  ASPECT_ORDER_CODE,
26
26
  BAD_ASPECT_BLOCK_CODE,
27
+ BAD_VALUE_CODE,
27
28
  COLUMN_RENAMED_CODE,
28
29
  COUNT_MISMATCH_CODE,
30
+ DUPLICATE_ATTRIBUTE_CODE,
31
+ JSON_NONSTANDARD_NUMBER_CODE,
32
+ MULTI_ASPECT_FRAGMENT_CODE,
33
+ PRECISION_CODE,
29
34
  STATUS_FAILED_CODE,
30
35
  STATUS_WARNING_CODE,
31
36
  } from "./codes.js";
37
+ import { agree, plural } from "./plural.js";
32
38
  import { type ImportReportBuilder } from "./report.js";
39
+ import { trimTrailingZeros } from "./text.js";
33
40
 
34
41
  // ============================================================ exact integers
35
42
 
36
- /** A run of 16 digits not inside a fraction: the shortest integer literal that can exceed 2^53 (9007199254740992). */
43
+ /**
44
+ * A run of 16 digits not inside a fraction: the shortest integer literal that can exceed 2^53 (9007199254740992).
45
+ * @category Plugin helpers
46
+ */
37
47
  export const MAYBE_UNSAFE_INTEGER = /(?<![0-9.])[0-9]{16}/;
38
48
 
39
49
  /**
40
- * The prefix of the string a non-standard token or an exact integer is rewritten to (a NUL
41
- * character first, which no sensible attribute value starts with).
50
+ * A literal the double read for may not equal: an exponent of 15 or more (`9.007199254740993e15`,
51
+ * `1e400`: an integer beyond 2^53 or an overflow), an exponent of -300 or less (`1e-400`: an
52
+ * underflow to 0), or 16 or more digits split by a decimal point before an exponent
53
+ * (`90071992547409.93e2`). MAYBE_UNSAFE_INTEGER sees none of them.
54
+ * ponytail: a literal that underflows through 300+ leading fraction zeros and no exponent is not
55
+ * gated; add a digit-count check if such files turn up.
56
+ * @category Plugin helpers
42
57
  */
43
- const SENTINEL = `${String.fromCharCode(0)}graph-io:`;
58
+ export const MAYBE_INEXACT_EXPONENT = new RegExp(
59
+ [
60
+ /\d[eE]\+?0*(?:1[5-9]|[2-9]\d|[1-9]\d{2,})/, // an exponent of 15 or more
61
+ /\d[eE]-0*(?:[3-9]\d{2}|[1-9]\d{3,})/, // an exponent of -300 or less
62
+ /(?<![\d.])(?=(?:\d\.?){16})\d+\.\d+[eE]/, // 16 or more digits split by a decimal point
63
+ ]
64
+ .map((r) => r.source)
65
+ .join("|"),
66
+ );
44
67
 
45
- /** The sentinel prefix of an integer literal kept as its digits. */
46
- const EXACT_SENTINEL = `${SENTINEL}exact:`;
68
+ /**
69
+ * The prefix of the string a non-standard token or an exact integer is rewritten to (a NUL
70
+ * character first, which no sensible attribute value starts with). A document that already holds
71
+ * the text gets a numbered variant, so a genuine string is never revived as a number.
72
+ */
73
+ const SENTINEL = `${String.fromCodePoint(0)}graph-io`;
47
74
 
48
75
  /** The non-standard tokens Python's json module writes, longest first so -Infinity wins over a bare minus. */
49
76
  const NONSTANDARD_TOKENS: readonly (readonly [string, number])[] = [
50
77
  ["-Infinity", -Infinity],
51
78
  ["Infinity", Infinity],
52
- ["NaN", NaN],
79
+ ["NaN", Number.NaN],
53
80
  ];
54
81
 
55
82
  /** A JSON integer literal (no fraction, no exponent, no leading zero), as CANONICAL_INTEGER in common/ids.ts. */
56
83
  const INTEGER_LITERAL = /^-?(0|[1-9][0-9]*)$/;
57
84
 
85
+ /** A JSON number literal, split into sign, integer digits, fraction digits and exponent. */
86
+ const NUMBER_PARTS = /^(-?)(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/;
87
+
58
88
  /** What rewriteNumbers() found. */
59
89
  interface RewrittenNumbers {
60
90
  /** The rewritten text. */
@@ -63,19 +93,106 @@ interface RewrittenNumbers {
63
93
  readonly tokens: Set<string>;
64
94
  /** The integer literals beyond 2^53 that were quoted. */
65
95
  readonly bigIntegers: string[];
96
+ /**
97
+ * The literals with a fraction or an exponent that denote an integer beyond 2^53 a double cannot
98
+ * hold (`9007199254740993.0`), and the finite literals that overflow to an infinity (`1e400`) or
99
+ * underflow to 0 (`1e-400`):
100
+ * read as the nearest double, which changes the value.
101
+ */
102
+ readonly inexact: string[];
103
+ /** The JSON.parse reviver that turns the sentinel strings of this rewrite back into numbers. */
104
+ readonly revive: (key: string, value: unknown) => unknown;
105
+ /** The JSON.parse reviver that turns this rewrite's exact sentinels into ExactInteger. */
106
+ readonly reviveExact: (key: string, value: unknown) => unknown;
107
+ }
108
+
109
+ /**
110
+ * The sentinel prefix of one rewrite: SENTINEL with a colon, or a numbered variant when the document
111
+ * already holds that one. A string can only decode to a NUL by escaping it (`\u0000`), so when the
112
+ * text holds that escape the escaped strings are decoded and searched too: any other escape in them
113
+ * (`graph\u002dio:`) would hide the sentinel from a search of the raw text.
114
+ * @param text - the document text
115
+ * @returns the prefix
116
+ */
117
+ function sentinelFor(text: string): string {
118
+ let haystack = text;
119
+ if (/\\u0000/i.test(text)) {
120
+ const decoded: string[] = [];
121
+ for (const [literal] of text.matchAll(/"(?:[^"\\]|\\.)*"/g)) {
122
+ if (literal.includes("\\")) {
123
+ decoded.push(decodeString(literal));
124
+ }
125
+ }
126
+ haystack = `${decoded.join("\n")}\n${text}`;
127
+ }
128
+ let k = 0;
129
+ while (haystack.includes(`graph-io${k === 0 ? "" : String(k)}:`)) {
130
+ k++;
131
+ }
132
+ return `${SENTINEL}${k === 0 ? "" : String(k)}:`;
133
+ }
134
+
135
+ /**
136
+ * Decode one JSON string literal, or give it back as written when it is not valid (the parse then
137
+ * reports the syntax error).
138
+ * @param literal - the literal with its quotes
139
+ * @returns the decoded value
140
+ */
141
+ function decodeString(literal: string): string {
142
+ try {
143
+ return String(JSON.parse(literal));
144
+ } catch {
145
+ return literal;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Whether a number literal with a fraction or an exponent reads back as another value: an integer
151
+ * beyond 2^53 written with more significant digits than a double keeps, which the nearest double
152
+ * misses, or a finite literal beyond the double range or a non-zero one below it.
153
+ * @param literal - the literal as written
154
+ * @returns true when the double read for it is not the value it denotes
155
+ */
156
+ function isInexactLiteral(literal: string): boolean {
157
+ const value = Number(literal);
158
+ if (!Number.isFinite(value)) {
159
+ return true;
160
+ }
161
+ if (value === 0) {
162
+ // a non-zero literal below the smallest double underflows to 0
163
+ return /[1-9]/.test(literal.split(/[eE]/)[0]);
164
+ }
165
+ if (Number.isSafeInteger(value) || !Number.isInteger(value)) {
166
+ return false;
167
+ }
168
+ const m = NUMBER_PARTS.exec(literal);
169
+ if (m === null) {
170
+ return false;
171
+ }
172
+ const digits = trimTrailingZeros(m[2] + (m[3] ?? ""));
173
+ const exponent = Number(m[4] ?? "0") - (m[3] ?? "").length + ((m[2] + (m[3] ?? "")).length - digits.length);
174
+ if (digits.replace(/^0+/, "").length <= 15 || exponent < 0) {
175
+ // up to 15 significant digits the nearest double reads back as the same text (1e39 is the
176
+ // float it says); a fraction that rounds to an integer is ordinary floating-point rounding
177
+ return false;
178
+ }
179
+ const exact = BigInt(digits) * 10n ** BigInt(exponent);
180
+ return exact !== BigInt(Math.abs(value));
66
181
  }
67
182
 
68
183
  /**
69
184
  * Rewrite the numbers JSON.parse cannot read exactly, outside strings and in value positions only:
70
185
  * NaN / Infinity / -Infinity become sentinel strings (when `nonstandard` is true), an integer
71
186
  * literal that is not a safe integer becomes a string of its digits (or, with `exactSentinel`, a
72
- * sentinel string reviveExact() turns into an ExactInteger). A container stack tells a value
73
- * position (after `:`, `[`, or `,` inside an array) from a key position, so `{NaN: 1}` stays invalid.
187
+ * sentinel string the result's reviveExact turns into an ExactInteger). A container stack tells a
188
+ * value position (after `:`, `[`, or `,` inside an array) from a key position, so `{NaN: 1}` stays
189
+ * invalid. Literals with a fraction or an exponent that no double holds are listed, not rewritten.
74
190
  * @param text - the document text
75
191
  * @param options - which rewrites apply
76
192
  * @param options.nonstandard - rewrite NaN / Infinity / -Infinity (default true)
77
193
  * @param options.exactSentinel - quote big integers as sentinels instead of bare digits (default false)
78
- * @returns the rewritten text, the non-standard tokens seen and the integer literals quoted
194
+ * @returns the rewritten text, what was found and the revivers of this rewrite
195
+ * @category Plugin helpers
79
196
  */
80
197
  export function rewriteNumbers(
81
198
  text: string,
@@ -83,9 +200,12 @@ export function rewriteNumbers(
83
200
  ): RewrittenNumbers {
84
201
  const nonstandard = options.nonstandard ?? true;
85
202
  const exactSentinel = options.exactSentinel ?? false;
203
+ const sentinel = sentinelFor(text);
204
+ const exactPrefix = `${sentinel}exact:`;
86
205
  const parts: string[] = [];
87
206
  const tokens = new Set<string>();
88
207
  const bigIntegers: string[] = [];
208
+ const inexact: string[] = [];
89
209
  const arrays: boolean[] = [];
90
210
  let expectValue = true;
91
211
  let copied = 0;
@@ -120,16 +240,20 @@ export function rewriteNumbers(
120
240
  if (token !== undefined) {
121
241
  end = i + token[0].length;
122
242
  tokens.add(token[0]);
123
- replacement = JSON.stringify(`${SENTINEL}${token[0]}`);
243
+ replacement = JSON.stringify(`${sentinel}${token[0]}`);
124
244
  } else if (ch === "-" || (ch >= "0" && ch <= "9")) {
125
245
  end = i + 1;
126
246
  while (end < n && "0123456789+-.eE".includes(text[end])) {
127
247
  end++;
128
248
  }
129
249
  const literal = text.slice(i, end);
130
- if (INTEGER_LITERAL.test(literal) && !Number.isSafeInteger(Number(literal))) {
131
- bigIntegers.push(literal);
132
- replacement = exactSentinel ? JSON.stringify(`${EXACT_SENTINEL}${literal}`) : `"${literal}"`;
250
+ if (INTEGER_LITERAL.test(literal)) {
251
+ if (!Number.isSafeInteger(Number(literal))) {
252
+ bigIntegers.push(literal);
253
+ replacement = exactSentinel ? JSON.stringify(`${exactPrefix}${literal}`) : `"${literal}"`;
254
+ }
255
+ } else if (isInexactLiteral(literal)) {
256
+ inexact.push(literal);
133
257
  }
134
258
  }
135
259
  if (replacement !== null) {
@@ -142,25 +266,185 @@ export function rewriteNumbers(
142
266
  i++;
143
267
  }
144
268
  parts.push(text.slice(copied));
145
- return { text: parts.join(""), tokens, bigIntegers };
269
+ const revive = (_key: string, value: unknown): unknown => {
270
+ if (typeof value === "string" && value.startsWith(sentinel)) {
271
+ const found = NONSTANDARD_TOKENS.find(([word]) => word === value.slice(sentinel.length));
272
+ return found === undefined ? value : found[1];
273
+ }
274
+ return value;
275
+ };
276
+ const reviveExact = (_key: string, value: unknown): unknown =>
277
+ typeof value === "string" && value.startsWith(exactPrefix)
278
+ ? new ExactInteger(value.slice(exactPrefix.length))
279
+ : value;
280
+ return { text: parts.join(""), tokens, bigIntegers, inexact, revive, reviveExact };
281
+ }
282
+
283
+ /** One key repeated in a JSON object. */
284
+ interface DuplicateKey {
285
+ /** The key. */
286
+ readonly key: string;
287
+ /** The UTF-16 offset of the repeated occurrence. */
288
+ readonly offset: number;
146
289
  }
147
290
 
291
+ /** Keys of one object compared pairwise up to this count; a wider object switches to a Set. */
292
+ const LINEAR_KEYS = 32;
293
+
148
294
  /**
149
- * The JSON.parse reviver that turns the non-standard sentinel strings of rewriteNumbers() back into
150
- * numbers.
151
- * @param _key - the member key (unused)
152
- * @param value - the parsed value
153
- * @returns the number for a sentinel string, the value otherwise
295
+ * The keys that occur twice in one object of a valid JSON text, which JSON.parse silently reduces
296
+ * to the last value (RFC 8259 section 4: names SHOULD be unique). One pass over the characters
297
+ * that jumps over strings; the keys of a small object are compared in place, without
298
+ * slicing (the parse itself costs about as much as this scan, so it allocates nothing per key).
299
+ * Keys are compared as written: `"\u0069d"` and `"id"` are not recognized as one key.
300
+ * @param text - a text JSON.parse accepted (or its rewrite)
301
+ * @returns every repetition in document order
302
+ * @category Plugin helpers
154
303
  */
155
- export function reviveNonstandard(_key: string, value: unknown): unknown {
156
- if (typeof value === "string" && value.startsWith(SENTINEL)) {
157
- const found = NONSTANDARD_TOKENS.find(([word]) => word === value.slice(SENTINEL.length));
158
- return found === undefined ? value : found[1];
304
+ export function findDuplicateKeys(text: string): DuplicateKey[] {
305
+ const found: DuplicateKey[] = [];
306
+ // per depth: the [start, end) of each key of the open object (reused), or -1 for an array
307
+ const spans: number[][] = [];
308
+ const wide: (Set<string> | null)[] = [];
309
+ let depth = 0;
310
+ const n = text.length;
311
+ let at = 0;
312
+ while (at < n) {
313
+ const c = text.codePointAt(at);
314
+ if (c === 123 || c === 91) {
315
+ // { or [
316
+ openContainer(spans, wide, depth, c === 91);
317
+ depth++;
318
+ } else if (c === 125 || c === 93) {
319
+ depth--;
320
+ } else if (c === 34) {
321
+ const end = closingQuote(text, at + 1);
322
+ const keys = depth > 0 ? spans[depth - 1] : null;
323
+ // an array's span list starts with -1: its strings are values, never keys
324
+ const isObjectKey = keys !== null && keys[0] !== -1 && isKey(text, end + 1);
325
+ if (isObjectKey && isRepeated(text, at + 1, end, keys, wide, depth - 1)) {
326
+ found.push({ key: text.slice(at + 1, end), offset: at });
327
+ }
328
+ at = end;
329
+ }
330
+ at++;
159
331
  }
160
- return value;
332
+ return found;
161
333
  }
162
334
 
163
- /** An integer literal beyond 2^53, kept as its exact digits (a CX id must never lose a digit). */
335
+ /**
336
+ * Start the key list of a container just opened at a depth (reusing the list of an earlier one).
337
+ * @param spans - the key spans per depth
338
+ * @param wide - the key sets per depth, for wide objects
339
+ * @param depth - the container's depth
340
+ * @param isArray - whether it is an array, whose strings are never keys
341
+ */
342
+ function openContainer(spans: number[][], wide: (Set<string> | null)[], depth: number, isArray: boolean): void {
343
+ if (spans.length === depth) {
344
+ spans.push([]);
345
+ wide.push(null);
346
+ }
347
+ spans[depth].length = 0;
348
+ if (isArray) {
349
+ spans[depth].push(-1);
350
+ }
351
+ wide[depth] = null;
352
+ }
353
+
354
+ /**
355
+ * Whether a key repeats one already seen in its object, remembering it otherwise.
356
+ * @param text - the text
357
+ * @param start - the key's first character
358
+ * @param end - the key's closing quote
359
+ * @param keys - the spans of the object's keys so far
360
+ * @param wide - the Set of each depth's keys once the object is wide
361
+ * @param level - the object's depth
362
+ * @returns true for a repetition
363
+ */
364
+ function isRepeated(
365
+ text: string,
366
+ start: number,
367
+ end: number,
368
+ keys: number[],
369
+ wide: (Set<string> | null)[],
370
+ level: number,
371
+ ): boolean {
372
+ const set = wide[level];
373
+ if (set !== null) {
374
+ const key = text.slice(start, end);
375
+ return set.has(key) || (set.add(key), false);
376
+ }
377
+ const length = end - start;
378
+ for (let k = 0; k < keys.length; k += 2) {
379
+ if (keys[k + 1] - keys[k] === length && sameText(text, keys[k], start, length)) {
380
+ return true;
381
+ }
382
+ }
383
+ keys.push(start, end);
384
+ if (keys.length > 2 * LINEAR_KEYS) {
385
+ const all = new Set<string>();
386
+ for (let k = 0; k < keys.length; k += 2) {
387
+ all.add(text.slice(keys[k], keys[k + 1]));
388
+ }
389
+ wide[level] = all;
390
+ }
391
+ return false;
392
+ }
393
+
394
+ /**
395
+ * Whether two ranges of one text hold the same characters.
396
+ * @param text - the text
397
+ * @param a - the first range's start
398
+ * @param b - the second range's start
399
+ * @param length - the ranges' length
400
+ * @returns true when equal
401
+ */
402
+ function sameText(text: string, a: number, b: number, length: number): boolean {
403
+ for (let i = 0; i < length; i++) {
404
+ if (text.codePointAt(a + i) !== text.codePointAt(b + i)) {
405
+ return false;
406
+ }
407
+ }
408
+ return true;
409
+ }
410
+
411
+ /**
412
+ * The offset of the quote that closes a JSON string.
413
+ * @param text - the text
414
+ * @param from - the offset after the opening quote
415
+ * @returns the closing quote's offset (the text length when there is none)
416
+ */
417
+ function closingQuote(text: string, from: number): number {
418
+ for (let q = text.indexOf('"', from); q >= 0; q = text.indexOf('"', q + 1)) {
419
+ let backslashes = 0;
420
+ while (text.codePointAt(q - 1 - backslashes) === 92) {
421
+ backslashes++;
422
+ }
423
+ if (backslashes % 2 === 0) {
424
+ return q;
425
+ }
426
+ }
427
+ return text.length;
428
+ }
429
+
430
+ /**
431
+ * Whether the string that ends before an offset is an object key: a colon follows after whitespace.
432
+ * @param text - the text
433
+ * @param from - the offset after the closing quote
434
+ * @returns true for a key
435
+ */
436
+ function isKey(text: string, from: number): boolean {
437
+ let i = from;
438
+ while (i < text.length && isSpace(text.codePointAt(i))) {
439
+ i++;
440
+ }
441
+ return text.codePointAt(i) === 58;
442
+ }
443
+
444
+ /**
445
+ * An integer literal beyond 2^53, kept as its exact digits (a CX id must never lose a digit).
446
+ * @category Plugin helpers
447
+ */
164
448
  export class ExactInteger {
165
449
  /** The digits, with a leading minus for a negative value. */
166
450
  readonly digits: string;
@@ -182,23 +466,11 @@ export class ExactInteger {
182
466
  }
183
467
  }
184
468
 
185
- /**
186
- * The JSON.parse reviver of an element whose big integers were rewritten as exact sentinels.
187
- * @param _key - the member key (unused)
188
- * @param value - the parsed value
189
- * @returns an ExactInteger for an exact sentinel, the value otherwise
190
- */
191
- function reviveExact(_key: string, value: unknown): unknown {
192
- if (typeof value === "string" && value.startsWith(EXACT_SENTINEL)) {
193
- return new ExactInteger(value.slice(EXACT_SENTINEL.length));
194
- }
195
- return value;
196
- }
197
-
198
469
  /**
199
470
  * Parse one JSON value, keeping integer literals beyond 2^53 as ExactInteger.
200
471
  * @param text - the JSON text
201
472
  * @returns the value and whether it may hold an ExactInteger; SyntaxError when it is not JSON
473
+ * @category Plugin helpers
202
474
  */
203
475
  export function parseExact(text: string): { readonly value: unknown; readonly exact: boolean } {
204
476
  if (!MAYBE_UNSAFE_INTEGER.test(text)) {
@@ -208,7 +480,7 @@ export function parseExact(text: string): { readonly value: unknown; readonly ex
208
480
  if (scan.bigIntegers.length === 0) {
209
481
  return { value: JSON.parse(text) as unknown, exact: false };
210
482
  }
211
- return { value: JSON.parse(scan.text, reviveExact) as unknown, exact: true };
483
+ return { value: JSON.parse(scan.text, scan.reviveExact) as unknown, exact: true };
212
484
  }
213
485
 
214
486
  /**
@@ -217,6 +489,7 @@ export function parseExact(text: string): { readonly value: unknown; readonly ex
217
489
  * @param value - the value
218
490
  * @param onPrecision - called with the digits of each integer that lost precision
219
491
  * @returns the plain JSON value
492
+ * @category Plugin helpers
220
493
  */
221
494
  export function plainJson(value: unknown, onPrecision?: (digits: string) => void): unknown {
222
495
  if (value instanceof ExactInteger) {
@@ -229,16 +502,236 @@ export function plainJson(value: unknown, onPrecision?: (digits: string) => void
229
502
  if (typeof value === "object" && value !== null) {
230
503
  const out: Record<string, unknown> = {};
231
504
  for (const [key, item] of Object.entries(value)) {
232
- out[key] = plainJson(item, onPrecision);
505
+ setOwn(out, key, plainJson(item, onPrecision));
233
506
  }
234
507
  return out;
235
508
  }
236
509
  return value;
237
510
  }
238
511
 
512
+ /**
513
+ * Set an own property, also for the key `__proto__` (which an assignment would turn into the
514
+ * object's prototype, losing the entry).
515
+ * @param target - the object
516
+ * @param key - the key
517
+ * @param value - the value
518
+ * @category Plugin helpers
519
+ */
520
+ export function setOwn(target: Record<string, unknown>, key: string, value: unknown): void {
521
+ if (key === "__proto__") {
522
+ Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true });
523
+ } else {
524
+ target[key] = value;
525
+ }
526
+ }
527
+
528
+ /**
529
+ * Parse a text with the bare tokens NaN / Infinity / -Infinity that Python's json writes.
530
+ * @param text - the JSON text that JSON.parse refused
531
+ * @returns the value, whether it may hold an ExactInteger and the tokens seen; null when the
532
+ * tokens are not what made the text invalid
533
+ */
534
+ function parseNonstandard(
535
+ text: string,
536
+ ): { readonly value: unknown; readonly exact: boolean; readonly tokens: ReadonlySet<string> } | null {
537
+ if (!/NaN|Infinity/.test(text)) {
538
+ return null;
539
+ }
540
+ const scan = rewriteNumbers(text, { nonstandard: true, exactSentinel: true });
541
+ if (scan.tokens.size === 0) {
542
+ return null;
543
+ }
544
+ try {
545
+ const value = JSON.parse(scan.text, (key, raw) => scan.reviveExact(key, scan.revive(key, raw))) as unknown;
546
+ return { value, exact: scan.bigIntegers.length > 0, tokens: scan.tokens };
547
+ } catch {
548
+ return null;
549
+ }
550
+ }
551
+
552
+ /** A lone surrogate code unit: a high one not followed by a low one, or a low one not after a high one. */
553
+ /** The escapes of the surrogate range, as a message writes them. */
554
+ const SURROGATE_ESCAPES = String.raw`\uD800-\uDFFF`;
555
+
556
+ const LONE_SURROGATE = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/g;
557
+
558
+ /** Text that may hold a surrogate: an escaped one, or a raw one (string input). */
559
+ const MAYBE_SURROGATE = /\\u[dD][89a-fA-F]|[\ud800-\udfff]/;
560
+
561
+ /**
562
+ * Replace every lone surrogate of a parsed value's strings and keys with U+FFFD, in place.
563
+ * @param value - the parsed value
564
+ * @param fixed - counts the strings repaired and names the keys two repaired keys collapse into
565
+ * @param fixed.count - the count
566
+ * @param fixed.collisions - a key one object holds twice once repaired (the later value is kept)
567
+ * @returns the value (a string, or an object with a repaired key, is returned as a new value)
568
+ */
569
+ function repairSurrogates(value: unknown, fixed: { count: number; collisions: string[] }): unknown {
570
+ if (typeof value === "string") {
571
+ const repaired = value.replaceAll(LONE_SURROGATE, "\ufffd");
572
+ if (repaired !== value) {
573
+ fixed.count++;
574
+ }
575
+ return repaired;
576
+ }
577
+ if (Array.isArray(value)) {
578
+ for (let i = 0; i < value.length; i++) {
579
+ value[i] = repairSurrogates(value[i], fixed);
580
+ }
581
+ return value;
582
+ }
583
+ if (isRecord(value)) {
584
+ const keys = Object.keys(value);
585
+ const repairedKeys = keys.map((key) => key.replaceAll(LONE_SURROGATE, "\ufffd"));
586
+ const renamed = repairedKeys.some((key, i) => key !== keys[i]);
587
+ const out: Record<string, unknown> = renamed ? {} : value;
588
+ keys.forEach((key, i) => {
589
+ if (repairedKeys[i] !== key) {
590
+ fixed.count++;
591
+ }
592
+ if (renamed && Object.hasOwn(out, repairedKeys[i])) {
593
+ fixed.collisions.push(repairedKeys[i]);
594
+ }
595
+ setOwn(out, repairedKeys[i], repairSurrogates(value[key], fixed));
596
+ });
597
+ return out;
598
+ }
599
+ return value;
600
+ }
601
+
602
+ /**
603
+ * The number of keys of a parsed value, at every depth.
604
+ * @param value - the value
605
+ * @returns the count
606
+ */
607
+ function keyCount(value: unknown): number {
608
+ if (Array.isArray(value)) {
609
+ return value.reduce<number>((n, item) => n + keyCount(item), 0);
610
+ }
611
+ if (isRecord(value)) {
612
+ return Object.keys(value).reduce((n, key) => n + 1 + keyCount(value[key]), 0);
613
+ }
614
+ return 0;
615
+ }
616
+
617
+ /**
618
+ * The first key an object of a JSON text holds twice (JSON.parse keeps the later value silently).
619
+ * @param text - valid JSON text
620
+ * @returns the key, or null
621
+ */
622
+ function duplicateKey(text: string): string | null {
623
+ const stack: (Set<string> | null)[] = [];
624
+ let i = 0;
625
+ while (i < text.length) {
626
+ const c = text[i];
627
+ if (c === "{") {
628
+ stack.push(new Set());
629
+ } else if (c === "[") {
630
+ stack.push(null);
631
+ } else if (c === "}" || c === "]") {
632
+ stack.pop();
633
+ } else if (c === '"') {
634
+ const start = i;
635
+ i = stringEnd(text, i + 1);
636
+ const keys = stack.at(-1) ?? null;
637
+ if (keys !== null && text[skipSpace(text, i + 1)] === ":") {
638
+ const key = JSON.parse(text.slice(start, i + 1)) as string;
639
+ if (keys.has(key)) {
640
+ return key;
641
+ }
642
+ keys.add(key);
643
+ }
644
+ }
645
+ i++;
646
+ }
647
+ return null;
648
+ }
649
+
650
+ /**
651
+ * The offset of the quote that ends a JSON string, skipping escaped characters.
652
+ * @param text - the text
653
+ * @param from - the offset after the opening quote
654
+ * @returns the closing quote's offset, or the text length (or one past it) when there is none
655
+ */
656
+ function stringEnd(text: string, from: number): number {
657
+ let i = from;
658
+ while (i < text.length && text[i] !== '"') {
659
+ i += text[i] === "\\" ? 2 : 1;
660
+ }
661
+ return i;
662
+ }
663
+
664
+ /**
665
+ * The index of the first character at or after `from` that is not JSON whitespace.
666
+ * @param text - the text
667
+ * @param from - the first index
668
+ * @returns the index (text.length at the end)
669
+ */
670
+ function skipSpace(text: string, from: number): number {
671
+ let i = from;
672
+ while (i < text.length && isSpace(text.codePointAt(i))) {
673
+ i++;
674
+ }
675
+ return i;
676
+ }
677
+
678
+ /** A key followed by its colon, for the cheap duplicate-key check. */
679
+ const KEY_COLON = /"\s*:/g;
680
+
681
+ /**
682
+ * The checks of a parsed element that JSON.parse cannot make, recorded in the report: lone
683
+ * surrogates (replaced with U+FFFD, E_BAD_VALUE) and a key held twice by one object
684
+ * (W_DUPLICATE_ATTRIBUTE; the later value is the one read, also for two keys that a repair makes one).
685
+ * @param text - the element text
686
+ * @param parsed - the parsed element
687
+ * @param line - its line
688
+ * @param report - the report
689
+ * @returns the value, repaired
690
+ */
691
+ function checkParsed(text: string, parsed: unknown, line: number, report: ImportReportBuilder): unknown {
692
+ let value = parsed;
693
+ if (MAYBE_SURROGATE.test(text)) {
694
+ const fixed = { count: 0, collisions: [] as string[] };
695
+ value = repairSurrogates(value, fixed);
696
+ for (const key of fixed.collisions) {
697
+ report.warnOnce(
698
+ "validation-error",
699
+ DUPLICATE_ATTRIBUTE_CODE,
700
+ `an object holds two keys that are the same key ${JSON.stringify(key)} once their lone surrogates are replaced with U+FFFD; the later value is read`,
701
+ { line, element: key },
702
+ `${DUPLICATE_ATTRIBUTE_CODE}:json:${key}`,
703
+ );
704
+ }
705
+ if (fixed.count > 0) {
706
+ report.error(
707
+ "validation-error",
708
+ BAD_VALUE_CODE,
709
+ `${fixed.count} string${plural(fixed.count)} ${agree(fixed.count, "holds", "hold")} a lone surrogate (an unpaired ${SURROGATE_ESCAPES} escape); each is read with U+FFFD in its place`,
710
+ { line },
711
+ );
712
+ }
713
+ }
714
+ if (typeof value === "object" && value !== null && (text.match(KEY_COLON)?.length ?? 0) > keyCount(value)) {
715
+ const key = duplicateKey(text);
716
+ if (key !== null) {
717
+ report.warnOnce(
718
+ "validation-error",
719
+ DUPLICATE_ATTRIBUTE_CODE,
720
+ `an object holds the key ${JSON.stringify(key)} twice; the later value is read`,
721
+ { line, element: key },
722
+ `${DUPLICATE_ATTRIBUTE_CODE}:json:${key}`,
723
+ );
724
+ }
725
+ }
726
+ return value;
727
+ }
728
+
239
729
  // ============================================================ the streaming aspect scanner
240
730
 
241
- /** A problem with the document's JSON; the importer turns it into a fatal E_SYNTAX or E_EMPTY_INPUT. */
731
+ /**
732
+ * A problem with the document's JSON; the importer turns it into a fatal E_SYNTAX or E_EMPTY_INPUT.
733
+ * @category Plugin helpers
734
+ */
242
735
  export class JsonScanError extends Error {
243
736
  /** The 1-based line of the problem. */
244
737
  readonly line: number;
@@ -260,12 +753,19 @@ export class JsonScanError extends Error {
260
753
  }
261
754
  }
262
755
 
263
- /** One thing the scanner found in a CX document. */
756
+ /**
757
+ * One thing the scanner found in a CX document.
758
+ * @category Plugin helpers
759
+ */
264
760
  export type AspectEvent =
265
761
  | {
266
- /** A top-level value that is not an array: the whole document, parsed. */
762
+ /**
763
+ * A top-level value that is not an array: a scalar document, parsed; an object is not
764
+ * read at all (object true, value undefined), however large it is.
765
+ */
267
766
  readonly kind: "root";
268
767
  readonly value: unknown;
768
+ readonly object: boolean;
269
769
  readonly line: number;
270
770
  }
271
771
  | {
@@ -273,6 +773,8 @@ export type AspectEvent =
273
773
  readonly kind: "block";
274
774
  readonly aspect: string;
275
775
  readonly block: number;
776
+ /** Whether the aspect is not the member's first key (`{"nodes": [...], "edges": [...]}`). */
777
+ readonly shared: boolean;
276
778
  readonly line: number;
277
779
  }
278
780
  | {
@@ -288,7 +790,7 @@ export type AspectEvent =
288
790
  readonly line: number;
289
791
  }
290
792
  | {
291
- /** Keys after the first one of a block member (`{"nodes": [...], "edges": [...]}`); their values were skipped. */
793
+ /** The keys of a block member whose values are not arrays (`{"nodes": [...], "x": 1}`, in any order); skipped. */
292
794
  readonly kind: "extraKeys";
293
795
  readonly aspect: string;
294
796
  readonly block: number;
@@ -312,6 +814,8 @@ export type AspectEvent =
312
814
  readonly block: number;
313
815
  readonly value: unknown;
314
816
  readonly exact: boolean;
817
+ /** The member's JSON text (for lexical checks of its ids). */
818
+ readonly text: string;
315
819
  readonly line: number;
316
820
  };
317
821
 
@@ -320,7 +824,7 @@ export type AspectEvent =
320
824
  * @param c - the character code
321
825
  * @returns true for space, line feed, carriage return and tab
322
826
  */
323
- const isSpace = (c: number): boolean => c === 32 || c === 10 || c === 13 || c === 9;
827
+ const isSpace = (c: number | undefined): boolean => c === 32 || c === 10 || c === 13 || c === 9;
324
828
 
325
829
  /**
326
830
  * Whether a character code ends a bare scalar (number, true, false, null).
@@ -378,6 +882,7 @@ class ChunkCursor {
378
882
  this.captureStart = 0;
379
883
  }
380
884
  this.linesBefore += countNewlines(this.buf, 0, this.buf.length);
885
+ const endsWithCr = this.buf.endsWith("\r");
381
886
  for (;;) {
382
887
  const next = await this.iterator.next();
383
888
  if (next.done === true) {
@@ -386,6 +891,10 @@ class ChunkCursor {
386
891
  return false;
387
892
  }
388
893
  if (next.value.length > 0) {
894
+ if (endsWithCr && next.value.startsWith("\n")) {
895
+ // a CR LF split across two chunks is one line break, counted with the CR
896
+ this.linesBefore--;
897
+ }
389
898
  this.buf = next.value;
390
899
  this.pos = 0;
391
900
  return true;
@@ -401,6 +910,15 @@ class ChunkCursor {
401
910
  return this.linesBefore + countNewlines(this.buf, 0, Math.min(this.pos, this.buf.length));
402
911
  }
403
912
 
913
+ /**
914
+ * The text from the read position to the end of the current chunk, at most `length` characters.
915
+ * @param length - the most characters
916
+ * @returns the text
917
+ */
918
+ ahead(length: number): string {
919
+ return this.buf.slice(this.pos, this.pos + length);
920
+ }
921
+
404
922
  /**
405
923
  * Skip whitespace and return the next character without consuming it.
406
924
  * @returns the character, or "" at the end of the input
@@ -537,7 +1055,7 @@ class ChunkCursor {
537
1055
  }
538
1056
 
539
1057
  /**
540
- * The number of line feeds in a slice of a string.
1058
+ * The number of line breaks in a slice of a string: LF, CR LF and a CR alone (old Mac files).
541
1059
  * @param text - the string
542
1060
  * @param from - the first index
543
1061
  * @param to - one past the last index
@@ -550,6 +1068,13 @@ function countNewlines(text: string, from: number, to: number): number {
550
1068
  count++;
551
1069
  i = text.indexOf("\n", i + 1);
552
1070
  }
1071
+ i = text.indexOf("\r", from);
1072
+ while (i >= 0 && i < to) {
1073
+ if (text[i + 1] !== "\n") {
1074
+ count++;
1075
+ }
1076
+ i = text.indexOf("\r", i + 1);
1077
+ }
553
1078
  return count;
554
1079
  }
555
1080
 
@@ -563,67 +1088,148 @@ function messageOf(err: unknown): string {
563
1088
  }
564
1089
 
565
1090
  /**
566
- * Parse a captured value, turning a parse failure into a JsonScanError at its line.
1091
+ * Parse a captured value, turning a parse failure into a JsonScanError at its line. With a report,
1092
+ * the bare tokens NaN / Infinity / -Infinity are read as numbers (W_JSON_NONSTANDARD_NUMBER), and
1093
+ * lone surrogates and repeated keys are recorded (checkParsed()).
567
1094
  * @param text - the value text
568
1095
  * @param line - the line the value starts on
1096
+ * @param report - the report, or null to only parse
569
1097
  * @returns the value and whether it may hold an ExactInteger
570
1098
  */
571
- function parseAt(text: string, line: number): { readonly value: unknown; readonly exact: boolean } {
1099
+ function parseAt(
1100
+ text: string,
1101
+ line: number,
1102
+ report: ImportReportBuilder | null,
1103
+ ): { readonly value: unknown; readonly exact: boolean } {
1104
+ let parsed: { readonly value: unknown; readonly exact: boolean };
572
1105
  try {
573
- return parseExact(text);
1106
+ parsed = parseExact(text);
574
1107
  } catch (err) {
575
- throw new JsonScanError(`invalid JSON: ${messageOf(err)}`, line);
1108
+ const recovered = report === null ? null : parseNonstandard(text);
1109
+ if (recovered === null) {
1110
+ throw new JsonScanError(`invalid JSON: ${messageOf(err)}`, line);
1111
+ }
1112
+ report?.warnOnce(
1113
+ "coercion",
1114
+ JSON_NONSTANDARD_NUMBER_CODE,
1115
+ `the document uses the non-standard token${plural(recovered.tokens.size)} ${[...recovered.tokens].join(", ")}, which strict JSON does not allow; read as numbers`,
1116
+ { line },
1117
+ );
1118
+ parsed = recovered;
1119
+ }
1120
+ if (report !== null) {
1121
+ parsed = { ...parsed, value: checkParsed(text, parsed.value, line, report) };
1122
+ }
1123
+ return parsed;
1124
+ }
1125
+
1126
+ /**
1127
+ * What a document that does not start with "[" looks like, for the syntax error: HTML or XML (an
1128
+ * error page saved under the file's name), a ZIP archive, or nothing in particular.
1129
+ * @param head - the first characters
1130
+ * @returns a sentence to append, or ""
1131
+ */
1132
+ function notJsonHint(head: string): string {
1133
+ if (head.startsWith("<")) {
1134
+ return "; the input looks like HTML or XML (an error page saved as the file?), not JSON";
1135
+ }
1136
+ if (head.startsWith("PK\u0003\u0004")) {
1137
+ return "; the input is a ZIP archive (a Cytoscape session .cys is read by the cys importer), not JSON";
576
1138
  }
1139
+ return "";
577
1140
  }
578
1141
 
579
1142
  /**
580
1143
  * Walk a CX document (CX1 or CX2): a top-level array of members, each normally a one-key object
581
1144
  * `{"<aspect>": [elements]}`. The elements of such a block are parsed and yielded one at a time;
582
1145
  * any other member is parsed whole and yielded as a "member". A document that is not an array is
583
- * yielded as one "root" event. Syntax errors throw JsonScanError.
1146
+ * yielded as one "root" event (an object without reading it). Syntax errors throw JsonScanError.
584
1147
  * @param chunks - the decoded text
1148
+ * @param report - where NaN tokens, lone surrogates and repeated keys are recorded (parseAt()); null to only parse
585
1149
  * @yields the events in document order
586
1150
  * @returns nothing
587
1151
  */
588
- export async function* scanAspects(chunks: AsyncIterable<string>): AsyncGenerator<AspectEvent, void, undefined> {
1152
+ export async function* scanAspects(
1153
+ chunks: AsyncIterable<string>,
1154
+ report: ImportReportBuilder | null = null,
1155
+ ): AsyncGenerator<AspectEvent, void, undefined> {
589
1156
  const cur = new ChunkCursor(chunks);
590
1157
  const first = await cur.peek();
591
1158
  if (first === "") {
592
1159
  throw new JsonScanError("the input is empty", 1, true);
593
1160
  }
594
- if (first !== "[") {
595
- const line = cur.line();
596
- cur.beginCapture();
597
- await cur.skipValue();
598
- const { value } = parseAt(cur.endCapture(), line);
599
- if ((await cur.peek()) !== "") {
600
- throw new JsonScanError("unexpected text after the document", cur.line());
601
- }
602
- yield { kind: "root", value, line };
1161
+ const hint = notJsonHint(cur.ahead(16));
1162
+ if (hint === "" && cur.ahead(8).includes(String.fromCodePoint(0))) {
1163
+ throw new JsonScanError(
1164
+ 'the input holds NUL characters: UTF-16 without a byte order mark (pass the encoding option, such as "utf-16le"), or binary data; not JSON',
1165
+ cur.line(),
1166
+ );
1167
+ }
1168
+ if (first === "[") {
1169
+ cur.pos++;
1170
+ yield* members(cur, report);
603
1171
  return;
604
1172
  }
605
- cur.pos++;
1173
+ const line = cur.line();
1174
+ if (first === "{") {
1175
+ yield { kind: "root", value: undefined, object: true, line };
1176
+ return;
1177
+ }
1178
+ yield { kind: "root", value: await scalarRoot(cur, line, hint), object: false, line };
1179
+ }
1180
+
1181
+ /**
1182
+ * Read a document that is neither an array nor an object: one value, and nothing after it.
1183
+ * @param cur - the cursor at the value
1184
+ * @param line - the value's line
1185
+ * @param hint - what the input looks like when it is not JSON, for the error
1186
+ * @returns the value
1187
+ */
1188
+ async function scalarRoot(cur: ChunkCursor, line: number, hint: string): Promise<unknown> {
1189
+ cur.beginCapture();
1190
+ await cur.skipValue();
1191
+ let value: unknown;
1192
+ try {
1193
+ ({ value } = parseAt(cur.endCapture(), line, null));
1194
+ } catch (err) {
1195
+ if (err instanceof JsonScanError && hint !== "") {
1196
+ throw new JsonScanError(`${err.message}${hint}`, err.line);
1197
+ }
1198
+ throw err;
1199
+ }
1200
+ if ((await cur.peek()) !== "") {
1201
+ throw new JsonScanError("unexpected text after the document", cur.line());
1202
+ }
1203
+ return value;
1204
+ }
1205
+
1206
+ /**
1207
+ * Read the members of the top-level array, and check that nothing follows it.
1208
+ * @param cur - the cursor after the opening bracket
1209
+ * @param report - the report of parseAt(), or null
1210
+ * @yields the members' events
1211
+ * @returns nothing
1212
+ */
1213
+ async function* members(
1214
+ cur: ChunkCursor,
1215
+ report: ImportReportBuilder | null,
1216
+ ): AsyncGenerator<AspectEvent, void, undefined> {
606
1217
  let block = 0;
607
- if ((await cur.peek()) === "]") {
1218
+ let next = await cur.peek();
1219
+ if (next === "]") {
608
1220
  cur.pos++;
609
- } else {
610
- for (;;) {
611
- yield* member(cur, block);
612
- block++;
613
- const next = await cur.peek();
614
- if (next === ",") {
615
- cur.pos++;
616
- continue;
617
- }
618
- if (next === "]") {
619
- cur.pos++;
620
- break;
621
- }
1221
+ }
1222
+ while (next !== "]") {
1223
+ yield* member(cur, block, report);
1224
+ block++;
1225
+ next = await cur.peek();
1226
+ if (next !== "," && next !== "]") {
622
1227
  throw new JsonScanError(
623
1228
  next === "" ? "the document ends before its closing bracket" : `expected "," or "]", found "${next}"`,
624
1229
  cur.line(),
625
1230
  );
626
1231
  }
1232
+ cur.pos++;
627
1233
  }
628
1234
  if ((await cur.peek()) !== "") {
629
1235
  throw new JsonScanError("unexpected text after the closing bracket", cur.line());
@@ -634,152 +1240,257 @@ export async function* scanAspects(chunks: AsyncIterable<string>): AsyncGenerato
634
1240
  * Read one member of the top-level array.
635
1241
  * @param cur - the cursor at the member's first character
636
1242
  * @param block - the member's position among the members
1243
+ * @param report - the report of parseAt(), or null
637
1244
  * @yields the member's events
638
1245
  * @returns nothing
639
1246
  */
640
- async function* member(cur: ChunkCursor, block: number): AsyncGenerator<AspectEvent, void, undefined> {
1247
+ async function* member(
1248
+ cur: ChunkCursor,
1249
+ block: number,
1250
+ report: ImportReportBuilder | null,
1251
+ ): AsyncGenerator<AspectEvent, void, undefined> {
641
1252
  const c = await cur.peek();
642
1253
  const line = cur.line();
643
1254
  if (c === "" || c === "]" || c === ",") {
644
1255
  throw new JsonScanError(c === "" ? "the document ends inside its array" : `unexpected "${c}"`, line);
645
1256
  }
1257
+ cur.beginCapture();
646
1258
  if (c !== "{") {
647
- cur.beginCapture();
648
1259
  await cur.skipValue();
649
- if (cur.deepest > MAX_ELEMENT_DEPTH) {
650
- cur.dropCapture();
651
- yield { kind: "deep", aspect: null, block, depth: cur.deepest, line };
652
- return;
653
- }
654
- const parsed = parseAt(cur.endCapture(), line);
655
- yield { kind: "member", block, value: parsed.value, exact: parsed.exact, line };
1260
+ yield wholeMember(cur, block, line, report);
656
1261
  return;
657
1262
  }
658
- cur.beginCapture();
659
1263
  cur.pos++;
660
- const k = await cur.peek();
661
- if (k !== '"') {
1264
+ if ((await cur.peek()) !== '"') {
662
1265
  // `{}` or not JSON: parse the whole member so a syntax error is reported as such
663
1266
  await cur.skipValue(1);
664
- if (cur.deepest > MAX_ELEMENT_DEPTH) {
665
- cur.dropCapture();
666
- yield { kind: "deep", aspect: null, block, depth: cur.deepest, line };
667
- return;
668
- }
669
- const parsed = parseAt(cur.endCapture(), line);
670
- yield { kind: "member", block, value: parsed.value, exact: parsed.exact, line };
1267
+ yield wholeMember(cur, block, line, report);
671
1268
  return;
672
1269
  }
673
- // the outer capture keeps the member's text in case it is not a block; readString nests its own
1270
+ // every array-valued key is a block whose elements stream, whatever the key order; any other
1271
+ // value is kept as text, so a member without an array is parsed whole (a CX2 descriptor, a
1272
+ // one-object aspect) and one with an array names its other keys (extraKeys), skipped
674
1273
  const outer = cur.endCapture();
675
- const keyLine = cur.line();
676
- const aspect = await cur.readString();
1274
+ const firstLine = cur.line();
1275
+ const keys: MemberKeys = { others: [], firstBlock: null, deep: 0 };
1276
+ let next = ",";
1277
+ let index = 0;
1278
+ while (next === ",") {
1279
+ const key = await readKey(cur);
1280
+ yield* keyValue(cur, key, index, block, keys, report);
1281
+ index++;
1282
+ next = await cur.peek();
1283
+ if (next !== "," && next !== "}") {
1284
+ throw new JsonScanError(
1285
+ next === "" ? "the document ends inside a member" : `expected "," or "}" after the key "${key.name}"`,
1286
+ cur.line(),
1287
+ );
1288
+ }
1289
+ cur.pos++;
1290
+ }
1291
+ yield* memberEnd(keys, block, line, { outer, firstLine }, report);
1292
+ }
1293
+
1294
+ /** What one-object member's keys held: the non-array values as text, the first block, the deepest value. */
1295
+ interface MemberKeys {
1296
+ readonly others: { key: string; text: string | null; line: number }[];
1297
+ firstBlock: string | null;
1298
+ deep: number;
1299
+ }
1300
+
1301
+ /**
1302
+ * The event of a member read whole (its capture already taken): the member, or "deep" when it
1303
+ * nests too deep to parse.
1304
+ * @param cur - the cursor after the member
1305
+ * @param block - the member's position among the members
1306
+ * @param line - the member's line
1307
+ * @param report - the report of parseAt(), or null
1308
+ * @returns the event
1309
+ */
1310
+ function wholeMember(cur: ChunkCursor, block: number, line: number, report: ImportReportBuilder | null): AspectEvent {
1311
+ if (cur.deepest > MAX_ELEMENT_DEPTH) {
1312
+ cur.dropCapture();
1313
+ return { kind: "deep", aspect: null, block, depth: cur.deepest, line };
1314
+ }
1315
+ const text = cur.endCapture();
1316
+ const parsed = parseAt(text, line, report);
1317
+ return { kind: "member", block, value: parsed.value, exact: parsed.exact, text, line };
1318
+ }
1319
+
1320
+ /**
1321
+ * Read a member's key and the colon after it.
1322
+ * @param cur - the cursor at the key
1323
+ * @returns the key and its line
1324
+ */
1325
+ async function readKey(cur: ChunkCursor): Promise<{ name: string; line: number }> {
1326
+ if ((await cur.peek()) !== '"') {
1327
+ throw new JsonScanError("expected a key", cur.line());
1328
+ }
1329
+ const line = cur.line();
1330
+ const name = await cur.readString();
677
1331
  if ((await cur.peek()) !== ":") {
678
- throw new JsonScanError(`expected ":" after the key "${aspect}"`, cur.line());
1332
+ throw new JsonScanError(`expected ":" after the key "${name}"`, cur.line());
679
1333
  }
680
1334
  cur.pos++;
681
- const v = await cur.peek();
682
- if (v !== "[") {
683
- // not a block: read the rest of the object and parse the member whole
684
- cur.beginCapture();
685
- await cur.skipValue(1);
686
- if (cur.deepest > MAX_ELEMENT_DEPTH) {
687
- cur.dropCapture();
688
- yield { kind: "deep", aspect, block, depth: cur.deepest, line };
689
- return;
690
- }
691
- const rest = cur.endCapture();
692
- const text = `${outer}${JSON.stringify(aspect)}:${rest}`;
693
- const parsed = parseAt(text, keyLine);
694
- yield { kind: "member", block, value: parsed.value, exact: parsed.exact, line };
1335
+ return { name, line };
1336
+ }
1337
+
1338
+ /**
1339
+ * Read one key's value: an array is a block whose elements stream; any other value is kept as text
1340
+ * (or, when it nests too deep to parse, only its depth).
1341
+ * @param cur - the cursor at the value
1342
+ * @param key - the key
1343
+ * @param key.name - its name
1344
+ * @param key.line - its line
1345
+ * @param index - the key's position in the member
1346
+ * @param block - the member's position among the members
1347
+ * @param keys - what the member's keys held so far
1348
+ * @param report - the report of parseAt(), or null
1349
+ * @yields the block's events
1350
+ * @returns nothing
1351
+ */
1352
+ async function* keyValue(
1353
+ cur: ChunkCursor,
1354
+ key: { name: string; line: number },
1355
+ index: number,
1356
+ block: number,
1357
+ keys: MemberKeys,
1358
+ report: ImportReportBuilder | null,
1359
+ ): AsyncGenerator<AspectEvent, void, undefined> {
1360
+ if ((await cur.peek()) === "[") {
1361
+ cur.pos++;
1362
+ yield { kind: "block", aspect: key.name, block, shared: index > 0, line: key.line };
1363
+ keys.firstBlock ??= key.name;
1364
+ yield* blockElements(cur, key.name, block, report);
695
1365
  return;
696
1366
  }
697
- cur.pos++;
698
- yield { kind: "block", aspect, block, line };
699
- if ((await cur.peek()) === "]") {
700
- cur.pos++;
1367
+ cur.beginCapture();
1368
+ await cur.skipValue();
1369
+ // the member's own brace is one level more
1370
+ if (cur.deepest + 1 > MAX_ELEMENT_DEPTH) {
1371
+ // never parse (recursively) what no one reads
1372
+ cur.dropCapture();
1373
+ keys.deep = Math.max(keys.deep, cur.deepest + 1);
1374
+ keys.others.push({ key: key.name, text: null, line: key.line });
701
1375
  } else {
702
- for (;;) {
703
- const e = await cur.peek();
704
- if (e === "") {
705
- throw new JsonScanError(`the document ends inside the "${aspect}" block`, cur.line());
706
- }
707
- const elementLine = cur.line();
708
- cur.beginCapture();
709
- await cur.skipValue();
710
- if (cur.deepest > MAX_ELEMENT_DEPTH) {
711
- cur.dropCapture();
712
- yield { kind: "deep", aspect, block, depth: cur.deepest, line: elementLine };
713
- } else {
714
- const text = cur.endCapture();
715
- const parsed = parseAt(text, elementLine);
716
- yield {
717
- kind: "element",
718
- aspect,
719
- block,
720
- value: parsed.value,
721
- exact: parsed.exact,
722
- text,
723
- line: elementLine,
724
- };
725
- }
726
- const next = await cur.peek();
727
- if (next === ",") {
728
- cur.pos++;
729
- continue;
730
- }
731
- if (next === "]") {
732
- cur.pos++;
733
- break;
1376
+ keys.others.push({ key: key.name, text: cur.endCapture(), line: key.line });
1377
+ }
1378
+ }
1379
+
1380
+ /**
1381
+ * The events after a one-object member's keys: with a block, its other keys (checked, then named
1382
+ * as extraKeys); without one, the member parsed whole from its kept text.
1383
+ * @param keys - what the member's keys held
1384
+ * @param block - the member's position among the members
1385
+ * @param line - the member's line
1386
+ * @param start - the member's opening text and the line after it
1387
+ * @param start.outer - the text up to the first key
1388
+ * @param start.firstLine - the line of the first key
1389
+ * @param report - the report of parseAt(), or null
1390
+ * @yields the events
1391
+ * @returns nothing
1392
+ */
1393
+ function* memberEnd(
1394
+ keys: MemberKeys,
1395
+ block: number,
1396
+ line: number,
1397
+ start: { outer: string; firstLine: number },
1398
+ report: ImportReportBuilder | null,
1399
+ ): Generator<AspectEvent, void, undefined> {
1400
+ const { others, firstBlock, deep } = keys;
1401
+ if (firstBlock !== null) {
1402
+ for (const other of others) {
1403
+ if (other.text !== null) {
1404
+ parseAt(other.text, other.line, null);
734
1405
  }
735
- throw new JsonScanError(
736
- next === ""
737
- ? `the document ends inside the "${aspect}" block`
738
- : `expected "," or "]" in the "${aspect}" block, found "${next}"`,
739
- cur.line(),
740
- );
741
1406
  }
742
- }
743
- const end = await cur.peek();
744
- if (end === "}") {
745
- cur.pos++;
1407
+ if (others.length > 0) {
1408
+ yield {
1409
+ kind: "extraKeys",
1410
+ aspect: firstBlock,
1411
+ block,
1412
+ keys: others.map((o) => o.key),
1413
+ line: others[0].line,
1414
+ };
1415
+ }
746
1416
  return;
747
1417
  }
748
- if (end !== ",") {
749
- throw new JsonScanError(
750
- end === "" ? "the document ends inside a member" : `expected "}" after the "${aspect}" block`,
751
- cur.line(),
752
- );
1418
+ if (deep > 0) {
1419
+ yield { kind: "deep", aspect: others[0].key, block, depth: deep, line };
1420
+ return;
753
1421
  }
754
- // more keys: a malformed block; skip their values and name them
755
- const keys: string[] = [];
756
- const extraLine = cur.line();
757
- while ((await cur.peek()) === ",") {
1422
+ const members = others.map((o) => [JSON.stringify(o.key), o.text ?? ""].join(":"));
1423
+ const text = `${start.outer}${members.join(",")}}`;
1424
+ const parsed = parseAt(text, start.firstLine, report);
1425
+ yield { kind: "member", block, value: parsed.value, exact: parsed.exact, text, line };
1426
+ }
1427
+
1428
+ /**
1429
+ * Read the elements of a block whose opening bracket was consumed, through its closing bracket.
1430
+ * @param cur - the cursor after the opening bracket
1431
+ * @param aspect - the block's aspect
1432
+ * @param block - the member's position among the members
1433
+ * @param report - the report of parseAt(), or null
1434
+ * @yields an element (or deep) event per element
1435
+ * @returns nothing
1436
+ */
1437
+ async function* blockElements(
1438
+ cur: ChunkCursor,
1439
+ aspect: string,
1440
+ block: number,
1441
+ report: ImportReportBuilder | null,
1442
+ ): AsyncGenerator<AspectEvent, void, undefined> {
1443
+ if ((await cur.peek()) === "]") {
758
1444
  cur.pos++;
759
- if ((await cur.peek()) !== '"') {
760
- throw new JsonScanError("expected a key", cur.line());
761
- }
762
- keys.push(await cur.readString());
763
- if ((await cur.peek()) !== ":") {
764
- throw new JsonScanError('expected ":"', cur.line());
1445
+ return;
1446
+ }
1447
+ for (;;) {
1448
+ const e = await cur.peek();
1449
+ if (e === "") {
1450
+ throw new JsonScanError(`the document ends inside the "${aspect}" block`, cur.line());
765
1451
  }
766
- cur.pos++;
767
- await cur.peek();
1452
+ const elementLine = cur.line();
768
1453
  cur.beginCapture();
769
1454
  await cur.skipValue();
770
- parseAt(cur.endCapture(), cur.line());
771
- }
772
- if ((await cur.peek()) !== "}") {
773
- throw new JsonScanError('expected "}"', cur.line());
1455
+ if (cur.deepest > MAX_ELEMENT_DEPTH) {
1456
+ cur.dropCapture();
1457
+ yield { kind: "deep", aspect, block, depth: cur.deepest, line: elementLine };
1458
+ } else {
1459
+ const text = cur.endCapture();
1460
+ const parsed = parseAt(text, elementLine, report);
1461
+ yield {
1462
+ kind: "element",
1463
+ aspect,
1464
+ block,
1465
+ value: parsed.value,
1466
+ exact: parsed.exact,
1467
+ text,
1468
+ line: elementLine,
1469
+ };
1470
+ }
1471
+ const next = await cur.peek();
1472
+ if (next === ",") {
1473
+ cur.pos++;
1474
+ continue;
1475
+ }
1476
+ if (next === "]") {
1477
+ cur.pos++;
1478
+ return;
1479
+ }
1480
+ throw new JsonScanError(
1481
+ next === ""
1482
+ ? `the document ends inside the "${aspect}" block`
1483
+ : `expected "," or "]" in the "${aspect}" block, found "${next}"`,
1484
+ cur.line(),
1485
+ );
774
1486
  }
775
- cur.pos++;
776
- yield { kind: "extraKeys", aspect, block, keys, line: extraLine };
777
1487
  }
778
1488
 
779
1489
  /**
780
1490
  * Report an element or member the scanner refused for its depth (E_BAD_ASPECT_BLOCK; skipped).
781
1491
  * @param report - the report
782
1492
  * @param event - the "deep" event
1493
+ * @category Plugin helpers
783
1494
  */
784
1495
  export function reportTooDeep(report: ImportReportBuilder, event: Extract<AspectEvent, { kind: "deep" }>): void {
785
1496
  report.error(
@@ -790,6 +1501,44 @@ export function reportTooDeep(report: ImportReportBuilder, event: Extract<Aspect
790
1501
  );
791
1502
  }
792
1503
 
1504
+ /**
1505
+ * The precision callback of plainJson() for an aspect kept verbatim in the metadata: W_PRECISION
1506
+ * once, since the digits stored there are not the file's.
1507
+ * @param report - the report
1508
+ * @param where - where the aspect is kept (meta.extra.cx, ...)
1509
+ * @returns the callback
1510
+ * @category Plugin helpers
1511
+ */
1512
+ export function keptPrecision(report: ImportReportBuilder, where: string): (digits: string) => void {
1513
+ return (digits) => {
1514
+ report.warnOnce(
1515
+ "precision",
1516
+ PRECISION_CODE,
1517
+ `an aspect kept in ${where} holds ${digits}, beyond 2^53; it is kept as the nearest double`,
1518
+ { element: where },
1519
+ `${PRECISION_CODE}:kept`,
1520
+ );
1521
+ };
1522
+ }
1523
+
1524
+ /**
1525
+ * Warn once (W_MULTI_ASPECT_FRAGMENT) about a block that shares its array member with another
1526
+ * aspect (`{"nodes": [...], "edges": [...]}`); a block of its own needs nothing.
1527
+ * @param report - the report
1528
+ * @param event - the "block" event
1529
+ * @category Plugin helpers
1530
+ */
1531
+ export function reportSharedBlock(report: ImportReportBuilder, event: Extract<AspectEvent, { kind: "block" }>): void {
1532
+ if (event.shared) {
1533
+ report.warnOnce(
1534
+ "coercion",
1535
+ MULTI_ASPECT_FRAGMENT_CODE,
1536
+ `the "${event.aspect}" aspect shares an array member with another aspect; each is read as its own fragment`,
1537
+ { line: event.line, element: event.aspect },
1538
+ );
1539
+ }
1540
+ }
1541
+
793
1542
  // ============================================================ the CX id rule (design section 1.0.2)
794
1543
 
795
1544
  /** How an id was spelled, when it was not a plain safe integer. */
@@ -812,6 +1561,7 @@ const DECIMAL_INTEGER_TEXT = /^-?(0|[1-9][0-9]*)$/;
812
1561
  * @param raw - the parsed value
813
1562
  * @param inexactLiteral - whether the number was written as a non-integer literal (`5.0`, `1e3`)
814
1563
  * @returns the id and how it was spelled
1564
+ * @category Plugin helpers
815
1565
  */
816
1566
  export function cxId(raw: unknown, inexactLiteral = false): CxId {
817
1567
  if (raw instanceof ExactInteger) {
@@ -843,18 +1593,25 @@ function invalidCxId(raw: unknown, reason: string): GraphFormatError {
843
1593
  });
844
1594
  }
845
1595
 
1596
+ /** A number literal at lastIndex (sticky). */
1597
+ const NUMBER_LITERAL = /-?\d[\d.eE+-]*/y;
1598
+
846
1599
  /**
847
1600
  * Whether the element text writes a top-level key's number as a non-integer literal (`"id": 5.0`,
848
1601
  * `"@id": 1e3`). A lexical check, so the parsed value (5) cannot tell. Only a key of the element
849
1602
  * itself counts, not one nested in its `v` (where `s` and `id` are ordinary attribute names).
850
1603
  * @param text - the element's JSON text
851
1604
  * @param key - the key
1605
+ * @param keyDepth - the nesting depth of the element's own keys: 1, or 2 for an element written as
1606
+ * a member's single object (`{"nodes": {"@id": 1.0}}`)
852
1607
  * @returns true when the literal holds a fraction or an exponent
1608
+ * @category Plugin helpers
853
1609
  */
854
- export function inexactLiteral(text: string, key: string): boolean {
1610
+ export function inexactLiteral(text: string, key: string, keyDepth = 1): boolean {
855
1611
  const quoted = JSON.stringify(key);
856
1612
  let depth = 0;
857
- for (let i = 0; i < text.length; i++) {
1613
+ let i = 0;
1614
+ while (i < text.length) {
858
1615
  const c = text[i];
859
1616
  if (c === "{" || c === "[") {
860
1617
  depth++;
@@ -862,24 +1619,36 @@ export function inexactLiteral(text: string, key: string): boolean {
862
1619
  depth--;
863
1620
  } else if (c === '"') {
864
1621
  const start = i;
865
- for (i++; i < text.length && text[i] !== '"'; i++) {
866
- if (text[i] === "\\") {
867
- i++;
868
- }
869
- }
1622
+ i = stringEnd(text, i + 1);
870
1623
  // the key itself (a string at depth 1 followed by a colon), not a value spelled like it
871
- if (depth === 1 && text.startsWith(quoted, start) && /^\s*:/.test(text.slice(i + 1, i + 16))) {
872
- const match = /^\s*:\s*(-?[0-9][0-9.eE+-]*)/.exec(text.slice(i + 1, i + 64));
873
- return match !== null && /[.eE]/.test(match[1]);
1624
+ const colon = skipSpace(text, i + 1);
1625
+ if (depth === keyDepth && text.startsWith(quoted, start) && text[colon] === ":") {
1626
+ return inexactNumberAt(text, skipSpace(text, colon + 1));
874
1627
  }
875
1628
  }
1629
+ i++;
876
1630
  }
877
1631
  return false;
878
1632
  }
879
1633
 
1634
+ /**
1635
+ * Whether a number literal at an offset holds a fraction or an exponent.
1636
+ * @param text - the text
1637
+ * @param at - the offset
1638
+ * @returns true when it does; false for an integer or no number
1639
+ */
1640
+ function inexactNumberAt(text: string, at: number): boolean {
1641
+ NUMBER_LITERAL.lastIndex = at;
1642
+ const match = NUMBER_LITERAL.exec(text);
1643
+ return match !== null && /[.eE]/.test(match[0]);
1644
+ }
1645
+
880
1646
  // ============================================================ Cytoscape positions (design section 1.0.2)
881
1647
 
882
- /** The name of the position column of every Cytoscape-family importer. */
1648
+ /**
1649
+ * The name of the position column of every Cytoscape-family importer.
1650
+ * @category Plugin helpers
1651
+ */
883
1652
  export const POSITION_COLUMN = "position";
884
1653
 
885
1654
  /** The name of the stacking-order column (Cytoscape's NODE_Z_LOCATION). */
@@ -890,6 +1659,7 @@ const Z_COLUMN = "z";
890
1659
  * @param format - the importer's format name
891
1660
  * @param sourceDims - 3 when z goes into the position (zAs "position")
892
1661
  * @returns the declaration
1662
+ * @category Plugin helpers
893
1663
  */
894
1664
  export function positionDecl(format: string, sourceDims = 2): ColumnDecl {
895
1665
  return {
@@ -908,6 +1678,7 @@ export function positionDecl(format: string, sourceDims = 2): ColumnDecl {
908
1678
  * The stacking-order column `z` (f64, origin namespace "cytoscape").
909
1679
  * @param format - the importer's format name
910
1680
  * @returns the declaration
1681
+ * @category Plugin helpers
911
1682
  */
912
1683
  export function zDecl(format: string): ColumnDecl {
913
1684
  return {
@@ -919,11 +1690,40 @@ export function zDecl(format: string): ColumnDecl {
919
1690
  };
920
1691
  }
921
1692
 
1693
+ /**
1694
+ * Whether a coordinate fits the f32 position column: finite once rounded to f32 (1e39 and the
1695
+ * Infinity of a literal like 1e400 do not, nor does NaN).
1696
+ * @param value - the coordinate
1697
+ * @returns true when the column can hold it
1698
+ * @category Plugin helpers
1699
+ */
1700
+ export function fitsF32(value: number): boolean {
1701
+ return Number.isFinite(Math.fround(value));
1702
+ }
1703
+
1704
+ /**
1705
+ * The head of an input as text for a sniff: UTF-16 by its byte order mark, else UTF-8, never
1706
+ * failing, without the BOM.
1707
+ * @param head - the first bytes
1708
+ * @returns the text
1709
+ * @category Plugin helpers
1710
+ */
1711
+ export function headText(head: Uint8Array): string {
1712
+ let encoding = "utf-8";
1713
+ if (head.byteLength >= 2 && head[0] === 0xff && head[1] === 0xfe) {
1714
+ encoding = "utf-16le";
1715
+ } else if (head.byteLength >= 2 && head[0] === 0xfe && head[1] === 0xff) {
1716
+ encoding = "utf-16be";
1717
+ }
1718
+ return new TextDecoder(encoding).decode(head).replace(/^\uFEFF/, "");
1719
+ }
1720
+
922
1721
  /**
923
1722
  * Cytoscape's screen y (growing downward) as graph-io's y (growing upward), and back: a negation
924
1723
  * that never yields -0.
925
1724
  * @param y - the coordinate
926
1725
  * @returns the flipped coordinate
1726
+ * @category Plugin helpers
927
1727
  */
928
1728
  export function flipY(y: number): number {
929
1729
  return y === 0 ? 0 : -y;
@@ -939,6 +1739,7 @@ const STRUCTURE_ASPECTS: ReadonlySet<string> = new Set(["metaData", "status", "n
939
1739
  * `metaData` or an aspect after the post-metadata or after `status` is W_ASPECT_ORDER; declared
940
1740
  * element counts are compared with what was read (W_COUNT_MISMATCH); `status.success: false` is
941
1741
  * fatal (E_STATUS_FAILED) and `success: true` with an error text a warning (W_STATUS_WARNING).
1742
+ * @category Plugin helpers
942
1743
  */
943
1744
  export class CxStructure {
944
1745
  private readonly report: ImportReportBuilder;
@@ -971,12 +1772,17 @@ export class CxStructure {
971
1772
  return this.statusSeen;
972
1773
  }
973
1774
 
1775
+ /** The name an aspect is counted under (CX1 reads old Cytoscape names under their cy names). */
1776
+ private readonly canonical: (name: string) => string;
1777
+
974
1778
  /**
975
1779
  * Create the checker.
976
1780
  * @param report - the report
1781
+ * @param canonical - the name an aspect named in metaData is counted under (default: itself)
977
1782
  */
978
- constructor(report: ImportReportBuilder) {
1783
+ constructor(report: ImportReportBuilder, canonical: (name: string) => string = (name) => name) {
979
1784
  this.report = report;
1785
+ this.canonical = canonical;
980
1786
  }
981
1787
 
982
1788
  /**
@@ -1039,9 +1845,7 @@ export class CxStructure {
1039
1845
  element(aspect: string, value: unknown, line: number): void {
1040
1846
  this.counts.set(aspect, (this.counts.get(aspect) ?? 0) + 1);
1041
1847
  if (aspect === "metaData") {
1042
- if (isRecord(value) && typeof value.name === "string" && typeof value.elementCount === "number") {
1043
- this.declared.set(value.name, value.elementCount);
1044
- }
1848
+ this.readCount(value, line);
1045
1849
  return;
1046
1850
  }
1047
1851
  if (aspect === "status") {
@@ -1049,6 +1853,36 @@ export class CxStructure {
1049
1853
  }
1050
1854
  }
1051
1855
 
1856
+ /**
1857
+ * Record the element count a metaData element declares; a count that is not an integer (text,
1858
+ * a fraction) or that names no aspect is W_COUNT_MISMATCH and not checked.
1859
+ * @param value - the metaData element
1860
+ * @param line - its line
1861
+ */
1862
+ private readCount(value: unknown, line: number): void {
1863
+ if (!isRecord(value) || value.elementCount === undefined || value.elementCount === null) {
1864
+ return;
1865
+ }
1866
+ const raw = value.elementCount;
1867
+ let count: number | null = null;
1868
+ if (raw instanceof ExactInteger) {
1869
+ count = Number(raw.digits);
1870
+ } else if (typeof raw === "number" && Number.isInteger(raw)) {
1871
+ count = raw;
1872
+ }
1873
+ if (typeof value.name !== "string" || count === null) {
1874
+ const shownCount = raw instanceof ExactInteger ? raw.digits : JSON.stringify(raw);
1875
+ this.report.warning(
1876
+ "validation-error",
1877
+ COUNT_MISMATCH_CODE,
1878
+ `a metaData element declares the elementCount ${shownCount} for the name ${JSON.stringify(value.name) ?? "undefined"}; not a count of a named aspect, not checked`,
1879
+ { line, element: "metaData" },
1880
+ );
1881
+ return;
1882
+ }
1883
+ this.declared.set(this.canonical(value.name), count);
1884
+ }
1885
+
1052
1886
  /**
1053
1887
  * Check a status element: success false is fatal, an error text with success true a warning.
1054
1888
  * @param value - the element
@@ -1098,7 +1932,7 @@ export class CxStructure {
1098
1932
  this.report.warning(
1099
1933
  "validation-error",
1100
1934
  COUNT_MISMATCH_CODE,
1101
- `metaData declares ${declared} "${name}" element(s); ${read} were read`,
1935
+ `metaData declares ${declared} "${name}" element${plural(declared)}; ${read} ${agree(read, "was", "were")} read`,
1102
1936
  { element: name },
1103
1937
  );
1104
1938
  }
@@ -1110,6 +1944,7 @@ export class CxStructure {
1110
1944
  * Whether a value is a plain JSON object.
1111
1945
  * @param value - any value
1112
1946
  * @returns true for a non-null, non-array object
1947
+ * @category Plugin helpers
1113
1948
  */
1114
1949
  export function isRecord(value: unknown): value is Record<string, unknown> {
1115
1950
  return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof ExactInteger);
@@ -1124,6 +1959,7 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
1124
1959
  * @param decl - the declaration
1125
1960
  * @param report - the report the rename is recorded in
1126
1961
  * @returns the handle
1962
+ * @category Plugin helpers
1127
1963
  */
1128
1964
  export function declareFresh(
1129
1965
  sink: GraphSink,