@graphty/graph-io 0.3.18 → 0.3.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -2
- package/dist/chunks/{escape-D-gZWO26.js → escape-B-tkk_Cl.js} +74 -16
- package/dist/chunks/escape-B-tkk_Cl.js.map +1 -0
- package/dist/chunks/{writer-GAdltGmC.js → export-Bh60Dv-n.js} +7 -252
- package/dist/chunks/export-Bh60Dv-n.js.map +1 -0
- package/dist/chunks/exporter-BGrsWamJ.js +736 -0
- package/dist/chunks/exporter-BGrsWamJ.js.map +1 -0
- package/dist/chunks/exporter-DIeGJXAZ.js +834 -0
- package/dist/chunks/exporter-DIeGJXAZ.js.map +1 -0
- package/dist/chunks/{importer-Br_QeAeE.js → importer-BWY2FFc5.js} +28 -6
- package/dist/chunks/importer-BWY2FFc5.js.map +1 -0
- package/dist/chunks/importer-BaQpCEcJ.js +2194 -0
- package/dist/chunks/importer-BaQpCEcJ.js.map +1 -0
- package/dist/chunks/importer-DD-xv9_Y.js +1471 -0
- package/dist/chunks/importer-DD-xv9_Y.js.map +1 -0
- package/dist/chunks/{importer-aNJfe0qu.js → importer-DEcKhsmQ.js} +2 -2
- package/dist/chunks/{importer-aNJfe0qu.js.map → importer-DEcKhsmQ.js.map} +1 -1
- package/dist/chunks/{importer-DHagxvDD.js → importer-DIrbbnAf.js} +6 -4
- package/dist/chunks/{importer-DHagxvDD.js.map → importer-DIrbbnAf.js.map} +1 -1
- package/dist/chunks/importer-D_e7e7LX.js +3055 -0
- package/dist/chunks/importer-D_e7e7LX.js.map +1 -0
- package/dist/chunks/{importer-Du5crN9l.js → importer-Wv4C4gaF.js} +278 -82
- package/dist/chunks/importer-Wv4C4gaF.js.map +1 -0
- package/dist/chunks/importer-vi3bSdtw.js +1713 -0
- package/dist/chunks/importer-vi3bSdtw.js.map +1 -0
- package/dist/chunks/{importer-d0uQxFp6.js → importer-zGLo8Dg_.js} +6 -4
- package/dist/chunks/{importer-d0uQxFp6.js.map → importer-zGLo8Dg_.js.map} +1 -1
- package/dist/chunks/json-elements-DDS8N2Dd.js +779 -0
- package/dist/chunks/json-elements-DDS8N2Dd.js.map +1 -0
- package/dist/chunks/{records-Bk9jgodz.js → records-dbRkxwaq.js} +2 -2
- package/dist/chunks/{records-Bk9jgodz.js.map → records-dbRkxwaq.js.map} +1 -1
- package/dist/chunks/{report-BOk0p5y8.js → report-B1z4WT9e.js} +143 -111
- package/dist/chunks/{report-BOk0p5y8.js.map → report-B1z4WT9e.js.map} +1 -1
- package/dist/chunks/weights-Dzba96G3.js +176 -0
- package/dist/chunks/weights-Dzba96G3.js.map +1 -0
- package/dist/chunks/writer-C7flM-Ih.js +77 -0
- package/dist/chunks/writer-C7flM-Ih.js.map +1 -0
- package/dist/csv.js +6 -4
- package/dist/csv.js.map +1 -1
- package/dist/cx.d.ts +1 -0
- package/dist/cx.js +6 -0
- package/dist/cx.js.map +1 -0
- package/dist/cx2.d.ts +1 -0
- package/dist/cx2.js +10 -0
- package/dist/cx2.js.map +1 -0
- package/dist/cys.d.ts +1 -0
- package/dist/cys.js +6 -0
- package/dist/cys.js.map +1 -0
- package/dist/dot.js +1 -1
- package/dist/gexf.js +16 -4
- package/dist/gexf.js.map +1 -1
- package/dist/gml.js +13 -14
- package/dist/gml.js.map +1 -1
- package/dist/graph-io.js +302 -399
- package/dist/graph-io.js.map +1 -1
- package/dist/graphml.js +1 -1
- package/dist/json.js +1 -1
- package/dist/neo4j.js +6 -4
- package/dist/neo4j.js.map +1 -1
- package/dist/obo.js +1 -1
- package/dist/pajek.js +1 -1
- package/dist/src/common/cell-budget.d.ts +84 -0
- package/dist/src/common/cell-budget.d.ts.map +1 -0
- package/dist/src/common/cell-budget.js +138 -0
- package/dist/src/common/cell-budget.js.map +1 -0
- package/dist/src/common/input.d.ts +10 -0
- package/dist/src/common/input.d.ts.map +1 -1
- package/dist/src/common/input.js +39 -0
- package/dist/src/common/input.js.map +1 -1
- package/dist/src/common/json-elements.d.ts +286 -0
- package/dist/src/common/json-elements.d.ts.map +1 -0
- package/dist/src/common/json-elements.js +926 -0
- package/dist/src/common/json-elements.js.map +1 -0
- package/dist/src/common/options.d.ts +6 -0
- package/dist/src/common/options.d.ts.map +1 -1
- package/dist/src/common/options.js +1 -1
- package/dist/src/common/options.js.map +1 -1
- package/dist/src/common/xml.d.ts +37 -3
- package/dist/src/common/xml.d.ts.map +1 -1
- package/dist/src/common/xml.js +86 -6
- package/dist/src/common/xml.js.map +1 -1
- package/dist/src/common/zip.d.ts +92 -0
- package/dist/src/common/zip.d.ts.map +1 -0
- package/dist/src/common/zip.js +399 -0
- package/dist/src/common/zip.js.map +1 -0
- package/dist/src/formats/cx/importer.d.ts +133 -0
- package/dist/src/formats/cx/importer.d.ts.map +1 -0
- package/dist/src/formats/cx/importer.js +2220 -0
- package/dist/src/formats/cx/importer.js.map +1 -0
- package/dist/src/formats/cx/index.d.ts +7 -0
- package/dist/src/formats/cx/index.d.ts.map +1 -0
- package/dist/src/formats/cx/index.js +7 -0
- package/dist/src/formats/cx/index.js.map +1 -0
- package/dist/src/formats/cx2/exporter.d.ts +59 -0
- package/dist/src/formats/cx2/exporter.d.ts.map +1 -0
- package/dist/src/formats/cx2/exporter.js +864 -0
- package/dist/src/formats/cx2/exporter.js.map +1 -0
- package/dist/src/formats/cx2/importer.d.ts +169 -0
- package/dist/src/formats/cx2/importer.d.ts.map +1 -0
- package/dist/src/formats/cx2/importer.js +1652 -0
- package/dist/src/formats/cx2/importer.js.map +1 -0
- package/dist/src/formats/cx2/index.d.ts +7 -0
- package/dist/src/formats/cx2/index.d.ts.map +1 -0
- package/dist/src/formats/cx2/index.js +7 -0
- package/dist/src/formats/cx2/index.js.map +1 -0
- package/dist/src/formats/cys/constants.d.ts +68 -0
- package/dist/src/formats/cys/constants.d.ts.map +1 -0
- package/dist/src/formats/cys/constants.js +76 -0
- package/dist/src/formats/cys/constants.js.map +1 -0
- package/dist/src/formats/cys/importer.d.ts +38 -0
- package/dist/src/formats/cys/importer.d.ts.map +1 -0
- package/dist/src/formats/cys/importer.js +830 -0
- package/dist/src/formats/cys/importer.js.map +1 -0
- package/dist/src/formats/cys/index.d.ts +7 -0
- package/dist/src/formats/cys/index.d.ts.map +1 -0
- package/dist/src/formats/cys/index.js +7 -0
- package/dist/src/formats/cys/index.js.map +1 -0
- package/dist/src/formats/cys/session.d.ts +132 -0
- package/dist/src/formats/cys/session.d.ts.map +1 -0
- package/dist/src/formats/cys/session.js +315 -0
- package/dist/src/formats/cys/session.js.map +1 -0
- package/dist/src/formats/cys/tables.d.ts +90 -0
- package/dist/src/formats/cys/tables.d.ts.map +1 -0
- package/dist/src/formats/cys/tables.js +293 -0
- package/dist/src/formats/cys/tables.js.map +1 -0
- package/dist/src/formats/dot/exporter.d.ts +2 -0
- package/dist/src/formats/dot/exporter.d.ts.map +1 -1
- package/dist/src/formats/dot/exporter.js +12 -0
- package/dist/src/formats/dot/exporter.js.map +1 -1
- package/dist/src/formats/dot/importer.js +6 -2
- package/dist/src/formats/dot/importer.js.map +1 -1
- package/dist/src/formats/gexf/exporter.d.ts +2 -0
- package/dist/src/formats/gexf/exporter.d.ts.map +1 -1
- package/dist/src/formats/gexf/exporter.js +6 -0
- package/dist/src/formats/gexf/exporter.js.map +1 -1
- package/dist/src/formats/gexf/importer.d.ts +0 -2
- package/dist/src/formats/gexf/importer.d.ts.map +1 -1
- package/dist/src/formats/gexf/importer.js +1 -3
- package/dist/src/formats/gexf/importer.js.map +1 -1
- package/dist/src/formats/gexf/index.d.ts +1 -1
- package/dist/src/formats/gexf/index.js +2 -2
- package/dist/src/formats/gexf/index.js.map +1 -1
- package/dist/src/formats/gml/exporter.d.ts +0 -8
- package/dist/src/formats/gml/exporter.d.ts.map +1 -1
- package/dist/src/formats/gml/exporter.js +8 -18
- package/dist/src/formats/gml/exporter.js.map +1 -1
- package/dist/src/formats/json/importer.d.ts.map +1 -1
- package/dist/src/formats/json/importer.js +6 -104
- package/dist/src/formats/json/importer.js.map +1 -1
- package/dist/src/formats/xgmml/columns.d.ts +152 -0
- package/dist/src/formats/xgmml/columns.d.ts.map +1 -0
- package/dist/src/formats/xgmml/columns.js +593 -0
- package/dist/src/formats/xgmml/columns.js.map +1 -0
- package/dist/src/formats/xgmml/constants.d.ts +196 -0
- package/dist/src/formats/xgmml/constants.d.ts.map +1 -0
- package/dist/src/formats/xgmml/constants.js +197 -0
- package/dist/src/formats/xgmml/constants.js.map +1 -0
- package/dist/src/formats/xgmml/document.d.ts +361 -0
- package/dist/src/formats/xgmml/document.d.ts.map +1 -0
- package/dist/src/formats/xgmml/document.js +695 -0
- package/dist/src/formats/xgmml/document.js.map +1 -0
- package/dist/src/formats/xgmml/emit.d.ts +341 -0
- package/dist/src/formats/xgmml/emit.d.ts.map +1 -0
- package/dist/src/formats/xgmml/emit.js +1275 -0
- package/dist/src/formats/xgmml/emit.js.map +1 -0
- package/dist/src/formats/xgmml/exporter.d.ts +24 -0
- package/dist/src/formats/xgmml/exporter.d.ts.map +1 -0
- package/dist/src/formats/xgmml/exporter.js +999 -0
- package/dist/src/formats/xgmml/exporter.js.map +1 -0
- package/dist/src/formats/xgmml/importer.d.ts +58 -0
- package/dist/src/formats/xgmml/importer.d.ts.map +1 -0
- package/dist/src/formats/xgmml/importer.js +360 -0
- package/dist/src/formats/xgmml/importer.js.map +1 -0
- package/dist/src/formats/xgmml/index.d.ts +8 -0
- package/dist/src/formats/xgmml/index.d.ts.map +1 -0
- package/dist/src/formats/xgmml/index.js +8 -0
- package/dist/src/formats/xgmml/index.js.map +1 -0
- package/dist/src/formats/xgmml/values.d.ts +60 -0
- package/dist/src/formats/xgmml/values.d.ts.map +1 -0
- package/dist/src/formats/xgmml/values.js +148 -0
- package/dist/src/formats/xgmml/values.js.map +1 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/registry.d.ts +7 -0
- package/dist/src/registry.d.ts.map +1 -1
- package/dist/src/registry.js +30 -10
- package/dist/src/registry.js.map +1 -1
- package/dist/src/sniff.d.ts +1 -1
- package/dist/src/sniff.d.ts.map +1 -1
- package/dist/src/sniff.js +4 -0
- package/dist/src/sniff.js.map +1 -1
- package/dist/xgmml.d.ts +1 -0
- package/dist/xgmml.js +9 -0
- package/dist/xgmml.js.map +1 -0
- package/package.json +25 -2
- package/src/common/cell-budget.ts +170 -0
- package/src/common/input.ts +40 -0
- package/src/common/json-elements.ts +1147 -0
- package/src/common/options.ts +1 -1
- package/src/common/xml.ts +127 -6
- package/src/common/zip.ts +472 -0
- package/src/formats/cx/importer.ts +2733 -0
- package/src/formats/cx/index.ts +7 -0
- package/src/formats/cx2/exporter.ts +1036 -0
- package/src/formats/cx2/importer.ts +2187 -0
- package/src/formats/cx2/index.ts +7 -0
- package/src/formats/cys/constants.ts +99 -0
- package/src/formats/cys/importer.ts +1100 -0
- package/src/formats/cys/index.ts +7 -0
- package/src/formats/cys/session.ts +428 -0
- package/src/formats/cys/tables.ts +400 -0
- package/src/formats/dot/exporter.ts +17 -0
- package/src/formats/dot/importer.ts +6 -2
- package/src/formats/gexf/exporter.ts +11 -0
- package/src/formats/gexf/importer.ts +1 -2
- package/src/formats/gexf/index.ts +1 -1
- package/src/formats/gml/exporter.ts +8 -19
- package/src/formats/json/importer.ts +6 -105
- package/src/formats/xgmml/columns.ts +747 -0
- package/src/formats/xgmml/constants.ts +265 -0
- package/src/formats/xgmml/document.ts +975 -0
- package/src/formats/xgmml/emit.ts +1563 -0
- package/src/formats/xgmml/exporter.ts +1215 -0
- package/src/formats/xgmml/importer.ts +491 -0
- package/src/formats/xgmml/index.ts +8 -0
- package/src/formats/xgmml/values.ts +183 -0
- package/src/index.ts +19 -0
- package/src/registry.ts +46 -15
- package/src/sniff.ts +18 -1
- package/dist/chunks/escape-D-gZWO26.js.map +0 -1
- package/dist/chunks/importer-Br_QeAeE.js.map +0 -1
- package/dist/chunks/importer-Du5crN9l.js.map +0 -1
- package/dist/chunks/writer-GAdltGmC.js.map +0 -1
|
@@ -0,0 +1,926 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The JSON reading the two CX formats share (design section 1.0 of
|
|
3
|
+
* design/graph-io/cytoscape-and-obo/design.md): the exact-integer rewrite (also used by the JSON
|
|
4
|
+
* importer), a streaming scanner that walks a CX document's top-level array and hands over each
|
|
5
|
+
* aspect element as soon as its closing brace arrives (section 1.2.5), and the CX id rule of
|
|
6
|
+
* section 1.0.2.
|
|
7
|
+
*
|
|
8
|
+
* The scanner never holds the document as one string, so a file larger than the longest string a
|
|
9
|
+
* JavaScript engine can hold is still read: it keeps the text of the element being read and
|
|
10
|
+
* nothing else. Each element is parsed with `JSON.parse`; an integer literal beyond 2^53 is kept as
|
|
11
|
+
* an ExactInteger (its digits) so an id never loses a digit.
|
|
12
|
+
*/
|
|
13
|
+
import { GraphFormatError, INVALID_INDEX, } from "@graphty/graph-format";
|
|
14
|
+
import { declareResolved, uniqueColumnName } from "./attributes.js";
|
|
15
|
+
import { ASPECT_ORDER_CODE, BAD_ASPECT_BLOCK_CODE, COLUMN_RENAMED_CODE, COUNT_MISMATCH_CODE, STATUS_FAILED_CODE, STATUS_WARNING_CODE, } from "./codes.js";
|
|
16
|
+
// ============================================================ exact integers
|
|
17
|
+
/** A run of 16 digits not inside a fraction: the shortest integer literal that can exceed 2^53 (9007199254740992). */
|
|
18
|
+
export const MAYBE_UNSAFE_INTEGER = /(?<![0-9.])[0-9]{16}/;
|
|
19
|
+
/**
|
|
20
|
+
* The prefix of the string a non-standard token or an exact integer is rewritten to (a NUL
|
|
21
|
+
* character first, which no sensible attribute value starts with).
|
|
22
|
+
*/
|
|
23
|
+
const SENTINEL = `${String.fromCharCode(0)}graph-io:`;
|
|
24
|
+
/** The sentinel prefix of an integer literal kept as its digits. */
|
|
25
|
+
const EXACT_SENTINEL = `${SENTINEL}exact:`;
|
|
26
|
+
/** The non-standard tokens Python's json module writes, longest first so -Infinity wins over a bare minus. */
|
|
27
|
+
const NONSTANDARD_TOKENS = [
|
|
28
|
+
["-Infinity", -Infinity],
|
|
29
|
+
["Infinity", Infinity],
|
|
30
|
+
["NaN", NaN],
|
|
31
|
+
];
|
|
32
|
+
/** A JSON integer literal (no fraction, no exponent, no leading zero), as CANONICAL_INTEGER in common/ids.ts. */
|
|
33
|
+
const INTEGER_LITERAL = /^-?(0|[1-9][0-9]*)$/;
|
|
34
|
+
/**
|
|
35
|
+
* Rewrite the numbers JSON.parse cannot read exactly, outside strings and in value positions only:
|
|
36
|
+
* NaN / Infinity / -Infinity become sentinel strings (when `nonstandard` is true), an integer
|
|
37
|
+
* literal that is not a safe integer becomes a string of its digits (or, with `exactSentinel`, a
|
|
38
|
+
* sentinel string reviveExact() turns into an ExactInteger). A container stack tells a value
|
|
39
|
+
* position (after `:`, `[`, or `,` inside an array) from a key position, so `{NaN: 1}` stays invalid.
|
|
40
|
+
* @param text - the document text
|
|
41
|
+
* @param options - which rewrites apply
|
|
42
|
+
* @param options.nonstandard - rewrite NaN / Infinity / -Infinity (default true)
|
|
43
|
+
* @param options.exactSentinel - quote big integers as sentinels instead of bare digits (default false)
|
|
44
|
+
* @returns the rewritten text, the non-standard tokens seen and the integer literals quoted
|
|
45
|
+
*/
|
|
46
|
+
export function rewriteNumbers(text, options = {}) {
|
|
47
|
+
const nonstandard = options.nonstandard ?? true;
|
|
48
|
+
const exactSentinel = options.exactSentinel ?? false;
|
|
49
|
+
const parts = [];
|
|
50
|
+
const tokens = new Set();
|
|
51
|
+
const bigIntegers = [];
|
|
52
|
+
const arrays = [];
|
|
53
|
+
let expectValue = true;
|
|
54
|
+
let copied = 0;
|
|
55
|
+
let i = 0;
|
|
56
|
+
const n = text.length;
|
|
57
|
+
while (i < n) {
|
|
58
|
+
const ch = text[i];
|
|
59
|
+
if (ch === '"') {
|
|
60
|
+
i++;
|
|
61
|
+
while (i < n && text[i] !== '"') {
|
|
62
|
+
i += text[i] === "\\" ? 2 : 1;
|
|
63
|
+
}
|
|
64
|
+
i++;
|
|
65
|
+
expectValue = false;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (ch === "{" || ch === "[") {
|
|
69
|
+
arrays.push(ch === "[");
|
|
70
|
+
expectValue = ch === "[";
|
|
71
|
+
}
|
|
72
|
+
else if (ch === "}" || ch === "]") {
|
|
73
|
+
arrays.pop();
|
|
74
|
+
expectValue = false;
|
|
75
|
+
}
|
|
76
|
+
else if (ch === ":") {
|
|
77
|
+
expectValue = true;
|
|
78
|
+
}
|
|
79
|
+
else if (ch === ",") {
|
|
80
|
+
expectValue = arrays.length > 0 && arrays[arrays.length - 1];
|
|
81
|
+
}
|
|
82
|
+
else if (expectValue && ch !== " " && ch !== "\t" && ch !== "\n" && ch !== "\r") {
|
|
83
|
+
expectValue = false;
|
|
84
|
+
const token = nonstandard ? NONSTANDARD_TOKENS.find(([word]) => text.startsWith(word, i)) : undefined;
|
|
85
|
+
let end = i;
|
|
86
|
+
let replacement = null;
|
|
87
|
+
if (token !== undefined) {
|
|
88
|
+
end = i + token[0].length;
|
|
89
|
+
tokens.add(token[0]);
|
|
90
|
+
replacement = JSON.stringify(`${SENTINEL}${token[0]}`);
|
|
91
|
+
}
|
|
92
|
+
else if (ch === "-" || (ch >= "0" && ch <= "9")) {
|
|
93
|
+
end = i + 1;
|
|
94
|
+
while (end < n && "0123456789+-.eE".includes(text[end])) {
|
|
95
|
+
end++;
|
|
96
|
+
}
|
|
97
|
+
const literal = text.slice(i, end);
|
|
98
|
+
if (INTEGER_LITERAL.test(literal) && !Number.isSafeInteger(Number(literal))) {
|
|
99
|
+
bigIntegers.push(literal);
|
|
100
|
+
replacement = exactSentinel ? JSON.stringify(`${EXACT_SENTINEL}${literal}`) : `"${literal}"`;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (replacement !== null) {
|
|
104
|
+
parts.push(text.slice(copied, i), replacement);
|
|
105
|
+
copied = end;
|
|
106
|
+
}
|
|
107
|
+
i = Math.max(end, i + 1);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
i++;
|
|
111
|
+
}
|
|
112
|
+
parts.push(text.slice(copied));
|
|
113
|
+
return { text: parts.join(""), tokens, bigIntegers };
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The JSON.parse reviver that turns the non-standard sentinel strings of rewriteNumbers() back into
|
|
117
|
+
* numbers.
|
|
118
|
+
* @param _key - the member key (unused)
|
|
119
|
+
* @param value - the parsed value
|
|
120
|
+
* @returns the number for a sentinel string, the value otherwise
|
|
121
|
+
*/
|
|
122
|
+
export function reviveNonstandard(_key, value) {
|
|
123
|
+
if (typeof value === "string" && value.startsWith(SENTINEL)) {
|
|
124
|
+
const found = NONSTANDARD_TOKENS.find(([word]) => word === value.slice(SENTINEL.length));
|
|
125
|
+
return found === undefined ? value : found[1];
|
|
126
|
+
}
|
|
127
|
+
return value;
|
|
128
|
+
}
|
|
129
|
+
/** An integer literal beyond 2^53, kept as its exact digits (a CX id must never lose a digit). */
|
|
130
|
+
export class ExactInteger {
|
|
131
|
+
/**
|
|
132
|
+
* Wrap the digits of an integer literal.
|
|
133
|
+
* @param digits - the literal
|
|
134
|
+
*/
|
|
135
|
+
constructor(digits) {
|
|
136
|
+
this.digits = digits;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The nearest number (JSON.stringify writes it as such).
|
|
140
|
+
* @returns the number
|
|
141
|
+
*/
|
|
142
|
+
toJSON() {
|
|
143
|
+
return Number(this.digits);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The JSON.parse reviver of an element whose big integers were rewritten as exact sentinels.
|
|
148
|
+
* @param _key - the member key (unused)
|
|
149
|
+
* @param value - the parsed value
|
|
150
|
+
* @returns an ExactInteger for an exact sentinel, the value otherwise
|
|
151
|
+
*/
|
|
152
|
+
function reviveExact(_key, value) {
|
|
153
|
+
if (typeof value === "string" && value.startsWith(EXACT_SENTINEL)) {
|
|
154
|
+
return new ExactInteger(value.slice(EXACT_SENTINEL.length));
|
|
155
|
+
}
|
|
156
|
+
return value;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Parse one JSON value, keeping integer literals beyond 2^53 as ExactInteger.
|
|
160
|
+
* @param text - the JSON text
|
|
161
|
+
* @returns the value and whether it may hold an ExactInteger; SyntaxError when it is not JSON
|
|
162
|
+
*/
|
|
163
|
+
export function parseExact(text) {
|
|
164
|
+
if (!MAYBE_UNSAFE_INTEGER.test(text)) {
|
|
165
|
+
return { value: JSON.parse(text), exact: false };
|
|
166
|
+
}
|
|
167
|
+
const scan = rewriteNumbers(text, { nonstandard: false, exactSentinel: true });
|
|
168
|
+
if (scan.bigIntegers.length === 0) {
|
|
169
|
+
return { value: JSON.parse(text), exact: false };
|
|
170
|
+
}
|
|
171
|
+
return { value: JSON.parse(scan.text, reviveExact), exact: true };
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* A parsed value with every ExactInteger replaced by its nearest number, for metadata and for
|
|
175
|
+
* values that are not ids.
|
|
176
|
+
* @param value - the value
|
|
177
|
+
* @param onPrecision - called with the digits of each integer that lost precision
|
|
178
|
+
* @returns the plain JSON value
|
|
179
|
+
*/
|
|
180
|
+
export function plainJson(value, onPrecision) {
|
|
181
|
+
if (value instanceof ExactInteger) {
|
|
182
|
+
onPrecision?.(value.digits);
|
|
183
|
+
return Number(value.digits);
|
|
184
|
+
}
|
|
185
|
+
if (Array.isArray(value)) {
|
|
186
|
+
return value.map((item) => plainJson(item, onPrecision));
|
|
187
|
+
}
|
|
188
|
+
if (typeof value === "object" && value !== null) {
|
|
189
|
+
const out = {};
|
|
190
|
+
for (const [key, item] of Object.entries(value)) {
|
|
191
|
+
out[key] = plainJson(item, onPrecision);
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
return value;
|
|
196
|
+
}
|
|
197
|
+
// ============================================================ the streaming aspect scanner
|
|
198
|
+
/** A problem with the document's JSON; the importer turns it into a fatal E_SYNTAX or E_EMPTY_INPUT. */
|
|
199
|
+
export class JsonScanError extends Error {
|
|
200
|
+
/**
|
|
201
|
+
* Create the error.
|
|
202
|
+
* @param message - what is wrong
|
|
203
|
+
* @param line - the 1-based line
|
|
204
|
+
* @param empty - whether the input is empty
|
|
205
|
+
*/
|
|
206
|
+
constructor(message, line, empty = false) {
|
|
207
|
+
super(message);
|
|
208
|
+
this.name = "JsonScanError";
|
|
209
|
+
this.line = line;
|
|
210
|
+
this.empty = empty;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Whether a character code is whitespace to JSON.
|
|
215
|
+
* @param c - the character code
|
|
216
|
+
* @returns true for space, line feed, carriage return and tab
|
|
217
|
+
*/
|
|
218
|
+
const isSpace = (c) => c === 32 || c === 10 || c === 13 || c === 9;
|
|
219
|
+
/**
|
|
220
|
+
* Whether a character code ends a bare scalar (number, true, false, null).
|
|
221
|
+
* @param c - the character code
|
|
222
|
+
* @returns true for whitespace, a comma, a closing bracket or brace, or a colon
|
|
223
|
+
*/
|
|
224
|
+
const endsScalar = (c) => isSpace(c) || c === 44 || c === 93 || c === 125 || c === 58;
|
|
225
|
+
const QUOTE_OR_BACKSLASH = /["\\]/g;
|
|
226
|
+
/**
|
|
227
|
+
* The deepest nesting of an element the scanner parses. graph-format refuses JSON values (a json
|
|
228
|
+
* cell, meta.extra) nested deeper than 256 levels, and a kept element sits a few levels down
|
|
229
|
+
* meta.extra; a deeper element is reported instead, before anything recurses into it.
|
|
230
|
+
*/
|
|
231
|
+
const MAX_ELEMENT_DEPTH = 200;
|
|
232
|
+
/**
|
|
233
|
+
* A cursor over text chunks with capture: the text of the value being read is kept across chunk
|
|
234
|
+
* boundaries as a list of parts, so a long element costs linear time.
|
|
235
|
+
*/
|
|
236
|
+
class ChunkCursor {
|
|
237
|
+
/**
|
|
238
|
+
* Wrap text chunks.
|
|
239
|
+
* @param chunks - the decoded text
|
|
240
|
+
*/
|
|
241
|
+
constructor(chunks) {
|
|
242
|
+
this.buf = "";
|
|
243
|
+
/** The read position in buf. */
|
|
244
|
+
this.pos = 0;
|
|
245
|
+
/** The line number of buf's first character. */
|
|
246
|
+
this.linesBefore = 1;
|
|
247
|
+
this.parts = null;
|
|
248
|
+
this.captureStart = 0;
|
|
249
|
+
/** The deepest nesting the last skipValue() reached. */
|
|
250
|
+
this.deepest = 0;
|
|
251
|
+
this.iterator = chunks[Symbol.asyncIterator]();
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Read the next chunk once the current one is consumed.
|
|
255
|
+
* @returns false at the end of the input
|
|
256
|
+
*/
|
|
257
|
+
async fill() {
|
|
258
|
+
if (this.parts !== null) {
|
|
259
|
+
this.parts.push(this.buf.slice(this.captureStart));
|
|
260
|
+
this.captureStart = 0;
|
|
261
|
+
}
|
|
262
|
+
this.linesBefore += countNewlines(this.buf, 0, this.buf.length);
|
|
263
|
+
for (;;) {
|
|
264
|
+
const next = await this.iterator.next();
|
|
265
|
+
if (next.done === true) {
|
|
266
|
+
this.buf = "";
|
|
267
|
+
this.pos = 0;
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
if (next.value.length > 0) {
|
|
271
|
+
this.buf = next.value;
|
|
272
|
+
this.pos = 0;
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* The 1-based line of the read position.
|
|
279
|
+
* @returns the line
|
|
280
|
+
*/
|
|
281
|
+
line() {
|
|
282
|
+
return this.linesBefore + countNewlines(this.buf, 0, Math.min(this.pos, this.buf.length));
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Skip whitespace and return the next character without consuming it.
|
|
286
|
+
* @returns the character, or "" at the end of the input
|
|
287
|
+
*/
|
|
288
|
+
async peek() {
|
|
289
|
+
for (;;) {
|
|
290
|
+
while (this.pos < this.buf.length && isSpace(this.buf.charCodeAt(this.pos))) {
|
|
291
|
+
this.pos++;
|
|
292
|
+
}
|
|
293
|
+
if (this.pos < this.buf.length) {
|
|
294
|
+
return this.buf[this.pos];
|
|
295
|
+
}
|
|
296
|
+
if (!(await this.fill())) {
|
|
297
|
+
return "";
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/** Start keeping the text from the read position. */
|
|
302
|
+
beginCapture() {
|
|
303
|
+
this.parts = [];
|
|
304
|
+
this.captureStart = this.pos;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Stop keeping text.
|
|
308
|
+
* @returns the text kept since beginCapture()
|
|
309
|
+
*/
|
|
310
|
+
endCapture() {
|
|
311
|
+
const parts = this.parts ?? [];
|
|
312
|
+
parts.push(this.buf.slice(this.captureStart, this.pos));
|
|
313
|
+
this.parts = null;
|
|
314
|
+
return parts.length === 1 ? parts[0] : parts.join("");
|
|
315
|
+
}
|
|
316
|
+
/** Drop the capture without building its text. */
|
|
317
|
+
dropCapture() {
|
|
318
|
+
this.parts = null;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Advance past one JSON value starting at the read position (which holds its first
|
|
322
|
+
* character), tracking strings and nesting only; the value is validated when it is parsed.
|
|
323
|
+
* @param startDepth - the nesting depth already entered (1 when the opening brace was consumed)
|
|
324
|
+
*/
|
|
325
|
+
async skipValue(startDepth = 0) {
|
|
326
|
+
let depth = startDepth;
|
|
327
|
+
this.deepest = depth;
|
|
328
|
+
if (depth === 0) {
|
|
329
|
+
const c = this.buf.charCodeAt(this.pos);
|
|
330
|
+
if (c === 34) {
|
|
331
|
+
this.pos++;
|
|
332
|
+
await this.skipString();
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (c !== 123 && c !== 91) {
|
|
336
|
+
await this.skipScalar();
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
for (;;) {
|
|
341
|
+
if (this.pos >= this.buf.length && !(await this.fill())) {
|
|
342
|
+
throw new JsonScanError("the document ends inside a value", this.line());
|
|
343
|
+
}
|
|
344
|
+
const c = this.buf.charCodeAt(this.pos);
|
|
345
|
+
this.pos++;
|
|
346
|
+
if (c === 34) {
|
|
347
|
+
await this.skipString();
|
|
348
|
+
}
|
|
349
|
+
else if (c === 123 || c === 91) {
|
|
350
|
+
depth++;
|
|
351
|
+
if (depth > this.deepest) {
|
|
352
|
+
this.deepest = depth;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
else if (c === 125 || c === 93) {
|
|
356
|
+
depth--;
|
|
357
|
+
if (depth === 0) {
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
/** Advance past the rest of a string whose opening quote was consumed. */
|
|
364
|
+
async skipString() {
|
|
365
|
+
for (;;) {
|
|
366
|
+
QUOTE_OR_BACKSLASH.lastIndex = this.pos;
|
|
367
|
+
const found = QUOTE_OR_BACKSLASH.exec(this.buf);
|
|
368
|
+
if (found === null) {
|
|
369
|
+
this.pos = this.buf.length;
|
|
370
|
+
if (!(await this.fill())) {
|
|
371
|
+
throw new JsonScanError("the document ends inside a string", this.line());
|
|
372
|
+
}
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
this.pos = found.index + 1;
|
|
376
|
+
if (found[0] === '"') {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
// a backslash: skip the escaped character, which may be in the next chunk
|
|
380
|
+
if (this.pos >= this.buf.length && !(await this.fill())) {
|
|
381
|
+
throw new JsonScanError("the document ends inside a string", this.line());
|
|
382
|
+
}
|
|
383
|
+
this.pos++;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/** Advance past a bare scalar (a number, true, false, null or a stray word). */
|
|
387
|
+
async skipScalar() {
|
|
388
|
+
for (;;) {
|
|
389
|
+
while (this.pos < this.buf.length && !endsScalar(this.buf.charCodeAt(this.pos))) {
|
|
390
|
+
this.pos++;
|
|
391
|
+
}
|
|
392
|
+
if (this.pos < this.buf.length || !(await this.fill())) {
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Read one JSON string literal at the read position (its opening quote).
|
|
399
|
+
* @returns the decoded string
|
|
400
|
+
*/
|
|
401
|
+
async readString() {
|
|
402
|
+
this.beginCapture();
|
|
403
|
+
this.pos++;
|
|
404
|
+
await this.skipString();
|
|
405
|
+
const literal = this.endCapture();
|
|
406
|
+
try {
|
|
407
|
+
return JSON.parse(literal);
|
|
408
|
+
}
|
|
409
|
+
catch (err) {
|
|
410
|
+
throw new JsonScanError(`invalid string: ${messageOf(err)}`, this.line());
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* The number of line feeds in a slice of a string.
|
|
416
|
+
* @param text - the string
|
|
417
|
+
* @param from - the first index
|
|
418
|
+
* @param to - one past the last index
|
|
419
|
+
* @returns the count
|
|
420
|
+
*/
|
|
421
|
+
function countNewlines(text, from, to) {
|
|
422
|
+
let count = 0;
|
|
423
|
+
let i = text.indexOf("\n", from);
|
|
424
|
+
while (i >= 0 && i < to) {
|
|
425
|
+
count++;
|
|
426
|
+
i = text.indexOf("\n", i + 1);
|
|
427
|
+
}
|
|
428
|
+
return count;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* The message of a caught value.
|
|
432
|
+
* @param err - the thrown value
|
|
433
|
+
* @returns the message
|
|
434
|
+
*/
|
|
435
|
+
function messageOf(err) {
|
|
436
|
+
return err instanceof Error ? err.message : String(err);
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Parse a captured value, turning a parse failure into a JsonScanError at its line.
|
|
440
|
+
* @param text - the value text
|
|
441
|
+
* @param line - the line the value starts on
|
|
442
|
+
* @returns the value and whether it may hold an ExactInteger
|
|
443
|
+
*/
|
|
444
|
+
function parseAt(text, line) {
|
|
445
|
+
try {
|
|
446
|
+
return parseExact(text);
|
|
447
|
+
}
|
|
448
|
+
catch (err) {
|
|
449
|
+
throw new JsonScanError(`invalid JSON: ${messageOf(err)}`, line);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Walk a CX document (CX1 or CX2): a top-level array of members, each normally a one-key object
|
|
454
|
+
* `{"<aspect>": [elements]}`. The elements of such a block are parsed and yielded one at a time;
|
|
455
|
+
* any other member is parsed whole and yielded as a "member". A document that is not an array is
|
|
456
|
+
* yielded as one "root" event. Syntax errors throw JsonScanError.
|
|
457
|
+
* @param chunks - the decoded text
|
|
458
|
+
* @yields the events in document order
|
|
459
|
+
* @returns nothing
|
|
460
|
+
*/
|
|
461
|
+
export async function* scanAspects(chunks) {
|
|
462
|
+
const cur = new ChunkCursor(chunks);
|
|
463
|
+
const first = await cur.peek();
|
|
464
|
+
if (first === "") {
|
|
465
|
+
throw new JsonScanError("the input is empty", 1, true);
|
|
466
|
+
}
|
|
467
|
+
if (first !== "[") {
|
|
468
|
+
const line = cur.line();
|
|
469
|
+
cur.beginCapture();
|
|
470
|
+
await cur.skipValue();
|
|
471
|
+
const { value } = parseAt(cur.endCapture(), line);
|
|
472
|
+
if ((await cur.peek()) !== "") {
|
|
473
|
+
throw new JsonScanError("unexpected text after the document", cur.line());
|
|
474
|
+
}
|
|
475
|
+
yield { kind: "root", value, line };
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
cur.pos++;
|
|
479
|
+
let block = 0;
|
|
480
|
+
if ((await cur.peek()) === "]") {
|
|
481
|
+
cur.pos++;
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
for (;;) {
|
|
485
|
+
yield* member(cur, block);
|
|
486
|
+
block++;
|
|
487
|
+
const next = await cur.peek();
|
|
488
|
+
if (next === ",") {
|
|
489
|
+
cur.pos++;
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
if (next === "]") {
|
|
493
|
+
cur.pos++;
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
496
|
+
throw new JsonScanError(next === "" ? "the document ends before its closing bracket" : `expected "," or "]", found "${next}"`, cur.line());
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if ((await cur.peek()) !== "") {
|
|
500
|
+
throw new JsonScanError("unexpected text after the closing bracket", cur.line());
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Read one member of the top-level array.
|
|
505
|
+
* @param cur - the cursor at the member's first character
|
|
506
|
+
* @param block - the member's position among the members
|
|
507
|
+
* @yields the member's events
|
|
508
|
+
* @returns nothing
|
|
509
|
+
*/
|
|
510
|
+
async function* member(cur, block) {
|
|
511
|
+
const c = await cur.peek();
|
|
512
|
+
const line = cur.line();
|
|
513
|
+
if (c === "" || c === "]" || c === ",") {
|
|
514
|
+
throw new JsonScanError(c === "" ? "the document ends inside its array" : `unexpected "${c}"`, line);
|
|
515
|
+
}
|
|
516
|
+
if (c !== "{") {
|
|
517
|
+
cur.beginCapture();
|
|
518
|
+
await cur.skipValue();
|
|
519
|
+
if (cur.deepest > MAX_ELEMENT_DEPTH) {
|
|
520
|
+
cur.dropCapture();
|
|
521
|
+
yield { kind: "deep", aspect: null, block, depth: cur.deepest, line };
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
const parsed = parseAt(cur.endCapture(), line);
|
|
525
|
+
yield { kind: "member", block, value: parsed.value, exact: parsed.exact, line };
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
cur.beginCapture();
|
|
529
|
+
cur.pos++;
|
|
530
|
+
const k = await cur.peek();
|
|
531
|
+
if (k !== '"') {
|
|
532
|
+
// `{}` or not JSON: parse the whole member so a syntax error is reported as such
|
|
533
|
+
await cur.skipValue(1);
|
|
534
|
+
if (cur.deepest > MAX_ELEMENT_DEPTH) {
|
|
535
|
+
cur.dropCapture();
|
|
536
|
+
yield { kind: "deep", aspect: null, block, depth: cur.deepest, line };
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
const parsed = parseAt(cur.endCapture(), line);
|
|
540
|
+
yield { kind: "member", block, value: parsed.value, exact: parsed.exact, line };
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
// the outer capture keeps the member's text in case it is not a block; readString nests its own
|
|
544
|
+
const outer = cur.endCapture();
|
|
545
|
+
const keyLine = cur.line();
|
|
546
|
+
const aspect = await cur.readString();
|
|
547
|
+
if ((await cur.peek()) !== ":") {
|
|
548
|
+
throw new JsonScanError(`expected ":" after the key "${aspect}"`, cur.line());
|
|
549
|
+
}
|
|
550
|
+
cur.pos++;
|
|
551
|
+
const v = await cur.peek();
|
|
552
|
+
if (v !== "[") {
|
|
553
|
+
// not a block: read the rest of the object and parse the member whole
|
|
554
|
+
cur.beginCapture();
|
|
555
|
+
await cur.skipValue(1);
|
|
556
|
+
if (cur.deepest > MAX_ELEMENT_DEPTH) {
|
|
557
|
+
cur.dropCapture();
|
|
558
|
+
yield { kind: "deep", aspect, block, depth: cur.deepest, line };
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
const rest = cur.endCapture();
|
|
562
|
+
const text = `${outer}${JSON.stringify(aspect)}:${rest}`;
|
|
563
|
+
const parsed = parseAt(text, keyLine);
|
|
564
|
+
yield { kind: "member", block, value: parsed.value, exact: parsed.exact, line };
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
cur.pos++;
|
|
568
|
+
yield { kind: "block", aspect, block, line };
|
|
569
|
+
if ((await cur.peek()) === "]") {
|
|
570
|
+
cur.pos++;
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
for (;;) {
|
|
574
|
+
const e = await cur.peek();
|
|
575
|
+
if (e === "") {
|
|
576
|
+
throw new JsonScanError(`the document ends inside the "${aspect}" block`, cur.line());
|
|
577
|
+
}
|
|
578
|
+
const elementLine = cur.line();
|
|
579
|
+
cur.beginCapture();
|
|
580
|
+
await cur.skipValue();
|
|
581
|
+
if (cur.deepest > MAX_ELEMENT_DEPTH) {
|
|
582
|
+
cur.dropCapture();
|
|
583
|
+
yield { kind: "deep", aspect, block, depth: cur.deepest, line: elementLine };
|
|
584
|
+
}
|
|
585
|
+
else {
|
|
586
|
+
const text = cur.endCapture();
|
|
587
|
+
const parsed = parseAt(text, elementLine);
|
|
588
|
+
yield {
|
|
589
|
+
kind: "element",
|
|
590
|
+
aspect,
|
|
591
|
+
block,
|
|
592
|
+
value: parsed.value,
|
|
593
|
+
exact: parsed.exact,
|
|
594
|
+
text,
|
|
595
|
+
line: elementLine,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
const next = await cur.peek();
|
|
599
|
+
if (next === ",") {
|
|
600
|
+
cur.pos++;
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
if (next === "]") {
|
|
604
|
+
cur.pos++;
|
|
605
|
+
break;
|
|
606
|
+
}
|
|
607
|
+
throw new JsonScanError(next === ""
|
|
608
|
+
? `the document ends inside the "${aspect}" block`
|
|
609
|
+
: `expected "," or "]" in the "${aspect}" block, found "${next}"`, cur.line());
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
const end = await cur.peek();
|
|
613
|
+
if (end === "}") {
|
|
614
|
+
cur.pos++;
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
if (end !== ",") {
|
|
618
|
+
throw new JsonScanError(end === "" ? "the document ends inside a member" : `expected "}" after the "${aspect}" block`, cur.line());
|
|
619
|
+
}
|
|
620
|
+
// more keys: a malformed block; skip their values and name them
|
|
621
|
+
const keys = [];
|
|
622
|
+
const extraLine = cur.line();
|
|
623
|
+
while ((await cur.peek()) === ",") {
|
|
624
|
+
cur.pos++;
|
|
625
|
+
if ((await cur.peek()) !== '"') {
|
|
626
|
+
throw new JsonScanError("expected a key", cur.line());
|
|
627
|
+
}
|
|
628
|
+
keys.push(await cur.readString());
|
|
629
|
+
if ((await cur.peek()) !== ":") {
|
|
630
|
+
throw new JsonScanError('expected ":"', cur.line());
|
|
631
|
+
}
|
|
632
|
+
cur.pos++;
|
|
633
|
+
await cur.peek();
|
|
634
|
+
cur.beginCapture();
|
|
635
|
+
await cur.skipValue();
|
|
636
|
+
parseAt(cur.endCapture(), cur.line());
|
|
637
|
+
}
|
|
638
|
+
if ((await cur.peek()) !== "}") {
|
|
639
|
+
throw new JsonScanError('expected "}"', cur.line());
|
|
640
|
+
}
|
|
641
|
+
cur.pos++;
|
|
642
|
+
yield { kind: "extraKeys", aspect, block, keys, line: extraLine };
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Report an element or member the scanner refused for its depth (E_BAD_ASPECT_BLOCK; skipped).
|
|
646
|
+
* @param report - the report
|
|
647
|
+
* @param event - the "deep" event
|
|
648
|
+
*/
|
|
649
|
+
export function reportTooDeep(report, event) {
|
|
650
|
+
report.error("parse-error", BAD_ASPECT_BLOCK_CODE, `${event.aspect === null ? "a member of the document" : `an element of "${event.aspect}"`} is nested ${event.depth} levels deep, more than the ${MAX_ELEMENT_DEPTH} a graph keeps; skipped`, { line: event.line, element: event.aspect });
|
|
651
|
+
}
|
|
652
|
+
const DECIMAL_INTEGER_TEXT = /^-?(0|[1-9][0-9]*)$/;
|
|
653
|
+
/**
|
|
654
|
+
* Apply the CX id rule (design section 1.0.2) to a parsed value: a safe integer is the id; an
|
|
655
|
+
* integer beyond 2^53 is its exact digit string; a string spelling a decimal integer is that
|
|
656
|
+
* integer (as Jackson coerces it); anything else is E_INVALID_ID.
|
|
657
|
+
* @param raw - the parsed value
|
|
658
|
+
* @param inexactLiteral - whether the number was written as a non-integer literal (`5.0`, `1e3`)
|
|
659
|
+
* @returns the id and how it was spelled
|
|
660
|
+
*/
|
|
661
|
+
export function cxId(raw, inexactLiteral = false) {
|
|
662
|
+
if (raw instanceof ExactInteger) {
|
|
663
|
+
return { id: raw.digits, note: "precision" };
|
|
664
|
+
}
|
|
665
|
+
if (typeof raw === "number") {
|
|
666
|
+
if (Number.isSafeInteger(raw)) {
|
|
667
|
+
return { id: raw === 0 ? 0 : raw, note: inexactLiteral ? "text" : null };
|
|
668
|
+
}
|
|
669
|
+
throw invalidCxId(raw, Number.isInteger(raw) ? "an integer beyond 2^53 written inexactly" : "not an integer");
|
|
670
|
+
}
|
|
671
|
+
if (typeof raw === "string" && raw !== "-0" && DECIMAL_INTEGER_TEXT.test(raw)) {
|
|
672
|
+
const n = Number(raw);
|
|
673
|
+
return Number.isSafeInteger(n) ? { id: n, note: "text" } : { id: raw, note: "precision" };
|
|
674
|
+
}
|
|
675
|
+
throw invalidCxId(raw, "a CX id is an integer");
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* The E_INVALID_ID error of a rejected CX id.
|
|
679
|
+
* @param raw - the value
|
|
680
|
+
* @param reason - why
|
|
681
|
+
* @returns the error
|
|
682
|
+
*/
|
|
683
|
+
function invalidCxId(raw, reason) {
|
|
684
|
+
const shown = typeof raw === "string" || typeof raw === "number" ? JSON.stringify(raw) : typeof raw;
|
|
685
|
+
return new GraphFormatError("E_INVALID_ID", `invalid id ${raw === null ? "null" : shown}: ${reason}`, {
|
|
686
|
+
reason,
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Whether the element text writes a top-level key's number as a non-integer literal (`"id": 5.0`,
|
|
691
|
+
* `"@id": 1e3`). A lexical check, so the parsed value (5) cannot tell. Only a key of the element
|
|
692
|
+
* itself counts, not one nested in its `v` (where `s` and `id` are ordinary attribute names).
|
|
693
|
+
* @param text - the element's JSON text
|
|
694
|
+
* @param key - the key
|
|
695
|
+
* @returns true when the literal holds a fraction or an exponent
|
|
696
|
+
*/
|
|
697
|
+
export function inexactLiteral(text, key) {
|
|
698
|
+
const quoted = JSON.stringify(key);
|
|
699
|
+
let depth = 0;
|
|
700
|
+
for (let i = 0; i < text.length; i++) {
|
|
701
|
+
const c = text[i];
|
|
702
|
+
if (c === "{" || c === "[") {
|
|
703
|
+
depth++;
|
|
704
|
+
}
|
|
705
|
+
else if (c === "}" || c === "]") {
|
|
706
|
+
depth--;
|
|
707
|
+
}
|
|
708
|
+
else if (c === '"') {
|
|
709
|
+
const start = i;
|
|
710
|
+
for (i++; i < text.length && text[i] !== '"'; i++) {
|
|
711
|
+
if (text[i] === "\\") {
|
|
712
|
+
i++;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
// the key itself (a string at depth 1 followed by a colon), not a value spelled like it
|
|
716
|
+
if (depth === 1 && text.startsWith(quoted, start) && /^\s*:/.test(text.slice(i + 1, i + 16))) {
|
|
717
|
+
const match = /^\s*:\s*(-?[0-9][0-9.eE+-]*)/.exec(text.slice(i + 1, i + 64));
|
|
718
|
+
return match !== null && /[.eE]/.test(match[1]);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
return false;
|
|
723
|
+
}
|
|
724
|
+
// ============================================================ Cytoscape positions (design section 1.0.2)
|
|
725
|
+
/** The name of the position column of every Cytoscape-family importer. */
|
|
726
|
+
export const POSITION_COLUMN = "position";
|
|
727
|
+
/** The name of the stacking-order column (Cytoscape's NODE_Z_LOCATION). */
|
|
728
|
+
const Z_COLUMN = "z";
|
|
729
|
+
/**
|
|
730
|
+
* The position column of a Cytoscape-family importer: f32 x3, y-up, 2 source dimensions.
|
|
731
|
+
* @param format - the importer's format name
|
|
732
|
+
* @param sourceDims - 3 when z goes into the position (zAs "position")
|
|
733
|
+
* @returns the declaration
|
|
734
|
+
*/
|
|
735
|
+
export function positionDecl(format, sourceDims = 2) {
|
|
736
|
+
return {
|
|
737
|
+
name: POSITION_COLUMN,
|
|
738
|
+
dtype: "f32",
|
|
739
|
+
components: 3,
|
|
740
|
+
role: "position",
|
|
741
|
+
mutable: true,
|
|
742
|
+
nullable: true,
|
|
743
|
+
extra: { sourceDims, units: "file" },
|
|
744
|
+
origin: { format, namespace: "cytoscape" },
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* The stacking-order column `z` (f64, origin namespace "cytoscape").
|
|
749
|
+
* @param format - the importer's format name
|
|
750
|
+
* @returns the declaration
|
|
751
|
+
*/
|
|
752
|
+
export function zDecl(format) {
|
|
753
|
+
return {
|
|
754
|
+
name: Z_COLUMN,
|
|
755
|
+
dtype: "f64",
|
|
756
|
+
nullable: true,
|
|
757
|
+
origin: { format, id: null, namespace: "cytoscape" },
|
|
758
|
+
extra: { cytoscape: "z" },
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Cytoscape's screen y (growing downward) as graph-io's y (growing upward), and back: a negation
|
|
763
|
+
* that never yields -0.
|
|
764
|
+
* @param y - the coordinate
|
|
765
|
+
* @returns the flipped coordinate
|
|
766
|
+
*/
|
|
767
|
+
export function flipY(y) {
|
|
768
|
+
return y === 0 ? 0 : -y;
|
|
769
|
+
}
|
|
770
|
+
// ============================================================ stream structure shared by CX1 and CX2
|
|
771
|
+
/** The aspect names of the stream structure, not of the network. */
|
|
772
|
+
const STRUCTURE_ASPECTS = new Set(["metaData", "status", "numberVerification"]);
|
|
773
|
+
/**
|
|
774
|
+
* The order, metadata and status rules CX1 and CX2 share (design section 1.0.1): a third
|
|
775
|
+
* `metaData` or an aspect after the post-metadata or after `status` is W_ASPECT_ORDER; declared
|
|
776
|
+
* element counts are compared with what was read (W_COUNT_MISMATCH); `status.success: false` is
|
|
777
|
+
* fatal (E_STATUS_FAILED) and `success: true` with an error text a warning (W_STATUS_WARNING).
|
|
778
|
+
*/
|
|
779
|
+
export class CxStructure {
|
|
780
|
+
/**
|
|
781
|
+
* Whether a status block was read.
|
|
782
|
+
* @returns true once a status block began
|
|
783
|
+
*/
|
|
784
|
+
get hasStatus() {
|
|
785
|
+
return this.statusSeen;
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Create the checker.
|
|
789
|
+
* @param report - the report
|
|
790
|
+
*/
|
|
791
|
+
constructor(report) {
|
|
792
|
+
/** Elements read per aspect name. */
|
|
793
|
+
this.counts = new Map();
|
|
794
|
+
/** Blocks read per aspect name. */
|
|
795
|
+
this.blocks = new Map();
|
|
796
|
+
/** The element counts the metadata declares, by aspect name (a later declaration wins). */
|
|
797
|
+
this.declared = new Map();
|
|
798
|
+
this.metaDataBlocks = 0;
|
|
799
|
+
this.networkBlocks = 0;
|
|
800
|
+
this.postMetadata = false;
|
|
801
|
+
this.statusSeen = false;
|
|
802
|
+
/** The status element, when one was read. */
|
|
803
|
+
this.status = undefined;
|
|
804
|
+
this.report = report;
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Record the start of a block (or a member holding one aspect).
|
|
808
|
+
* @param aspect - the aspect name
|
|
809
|
+
* @param line - its line
|
|
810
|
+
*/
|
|
811
|
+
block(aspect, line) {
|
|
812
|
+
this.blocks.set(aspect, (this.blocks.get(aspect) ?? 0) + 1);
|
|
813
|
+
if (this.statusSeen) {
|
|
814
|
+
this.report.warnOnce("validation-error", ASPECT_ORDER_CODE, `the "${aspect}" block comes after the status block, which must be last; it is read`, { line, element: aspect }, `${ASPECT_ORDER_CODE}:status`);
|
|
815
|
+
}
|
|
816
|
+
if (aspect === "metaData") {
|
|
817
|
+
this.metaDataBlocks++;
|
|
818
|
+
if (this.networkBlocks > 0) {
|
|
819
|
+
this.postMetadata = true;
|
|
820
|
+
}
|
|
821
|
+
if (this.metaDataBlocks > 2) {
|
|
822
|
+
this.report.warnOnce("validation-error", ASPECT_ORDER_CODE, `${this.metaDataBlocks} metaData blocks; a document has at most two (pre and post)`, { line, element: aspect }, `${ASPECT_ORDER_CODE}:metaData`);
|
|
823
|
+
}
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
if (aspect === "status") {
|
|
827
|
+
this.statusSeen = true;
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
if (STRUCTURE_ASPECTS.has(aspect)) {
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
this.networkBlocks++;
|
|
834
|
+
if (this.postMetadata) {
|
|
835
|
+
this.report.warnOnce("validation-error", ASPECT_ORDER_CODE, `the "${aspect}" block comes after the post-metadata, where only status is allowed; it is read`, { line, element: aspect }, `${ASPECT_ORDER_CODE}:post`);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Record one element of a block; metaData and status elements are checked here.
|
|
840
|
+
* @param aspect - the aspect name
|
|
841
|
+
* @param value - the parsed element
|
|
842
|
+
* @param line - its line
|
|
843
|
+
*/
|
|
844
|
+
element(aspect, value, line) {
|
|
845
|
+
this.counts.set(aspect, (this.counts.get(aspect) ?? 0) + 1);
|
|
846
|
+
if (aspect === "metaData") {
|
|
847
|
+
if (isRecord(value) && typeof value.name === "string" && typeof value.elementCount === "number") {
|
|
848
|
+
this.declared.set(value.name, value.elementCount);
|
|
849
|
+
}
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
if (aspect === "status") {
|
|
853
|
+
this.readStatus(value, line);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Check a status element: success false is fatal, an error text with success true a warning.
|
|
858
|
+
* @param value - the element
|
|
859
|
+
* @param line - its line
|
|
860
|
+
*/
|
|
861
|
+
readStatus(value, line) {
|
|
862
|
+
if (this.status !== undefined) {
|
|
863
|
+
this.report.warnOnce("validation-error", ASPECT_ORDER_CODE, "more than one status element; the last is used", { line, element: "status" }, `${ASPECT_ORDER_CODE}:status2`);
|
|
864
|
+
}
|
|
865
|
+
this.status = value;
|
|
866
|
+
if (!isRecord(value)) {
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
const error = typeof value.error === "string" ? value.error : "";
|
|
870
|
+
if (value.success === false) {
|
|
871
|
+
const message = `the producer marked the document as failed${error.length > 0 ? `: ${error}` : ""}`;
|
|
872
|
+
this.report.error("validation-error", STATUS_FAILED_CODE, message, { line, element: "status" });
|
|
873
|
+
throw this.report.abort(message, { code: STATUS_FAILED_CODE });
|
|
874
|
+
}
|
|
875
|
+
if (value.success === true && error.length > 0) {
|
|
876
|
+
this.report.warning("validation-error", STATUS_WARNING_CODE, `the producer reports a warning: ${error}`, {
|
|
877
|
+
line,
|
|
878
|
+
element: "status",
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Whether the status element is one object with a boolean success.
|
|
884
|
+
* @returns true for a well-formed status
|
|
885
|
+
*/
|
|
886
|
+
statusWellFormed() {
|
|
887
|
+
return isRecord(this.status) && typeof this.status.success === "boolean";
|
|
888
|
+
}
|
|
889
|
+
/** Compare the declared element counts with what was read (W_COUNT_MISMATCH per aspect). */
|
|
890
|
+
checkCounts() {
|
|
891
|
+
for (const [name, declared] of this.declared) {
|
|
892
|
+
const read = this.counts.get(name) ?? 0;
|
|
893
|
+
if (read !== declared) {
|
|
894
|
+
this.report.warning("validation-error", COUNT_MISMATCH_CODE, `metaData declares ${declared} "${name}" element(s); ${read} were read`, { element: name });
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Whether a value is a plain JSON object.
|
|
901
|
+
* @param value - any value
|
|
902
|
+
* @returns true for a non-null, non-array object
|
|
903
|
+
*/
|
|
904
|
+
export function isRecord(value) {
|
|
905
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof ExactInteger);
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Declare a column under a name no column of the table holds yet: a per-element visual property
|
|
909
|
+
* whose name an attribute already has gets `<name>#2` (W_COLUMN_RENAMED), even when the two share a
|
|
910
|
+
* shape, so the visual value never overwrites the attribute.
|
|
911
|
+
* @param sink - the sink
|
|
912
|
+
* @param domain - node or edge
|
|
913
|
+
* @param decl - the declaration
|
|
914
|
+
* @param report - the report the rename is recorded in
|
|
915
|
+
* @returns the handle
|
|
916
|
+
*/
|
|
917
|
+
export function declareFresh(sink, domain, decl, report) {
|
|
918
|
+
const taken = (name) => (domain === "node" ? sink.nodeColumn(name) : sink.edgeColumn(name)) !== INVALID_INDEX;
|
|
919
|
+
let fresh = decl;
|
|
920
|
+
if (taken(decl.name)) {
|
|
921
|
+
fresh = { ...decl, name: uniqueColumnName(decl.name, null, taken) };
|
|
922
|
+
report.warning("coercion", COLUMN_RENAMED_CODE, `${domain} column "${decl.name}" renamed to "${fresh.name}": an attribute holds that name`, { element: decl.name });
|
|
923
|
+
}
|
|
924
|
+
return declareResolved(sink, domain, fresh, report).handle;
|
|
925
|
+
}
|
|
926
|
+
//# sourceMappingURL=json-elements.js.map
|