@graphty/graph-io 0.3.17 → 0.3.19

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 (154) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +64 -3
  3. package/dist/chunks/{escape-scjHxjpr.js → escape-CWExcecC.js} +3 -2
  4. package/dist/chunks/{escape-scjHxjpr.js.map → escape-CWExcecC.js.map} +1 -1
  5. package/dist/chunks/exporter-CbMZyVt-.js +735 -0
  6. package/dist/chunks/exporter-CbMZyVt-.js.map +1 -0
  7. package/dist/chunks/importer-BAP4PrxR.js +2194 -0
  8. package/dist/chunks/importer-BAP4PrxR.js.map +1 -0
  9. package/dist/chunks/{importer-Xg07gk7p.js → importer-BNFuV1-K.js} +5 -3
  10. package/dist/chunks/{importer-Xg07gk7p.js.map → importer-BNFuV1-K.js.map} +1 -1
  11. package/dist/chunks/{importer-BmOl9gLW.js → importer-BVGtU1NA.js} +5 -3
  12. package/dist/chunks/{importer-BmOl9gLW.js.map → importer-BVGtU1NA.js.map} +1 -1
  13. package/dist/chunks/{importer-ruMWWvVs.js → importer-BW-Ft2ps.js} +5 -3
  14. package/dist/chunks/{importer-ruMWWvVs.js.map → importer-BW-Ft2ps.js.map} +1 -1
  15. package/dist/chunks/{importer-B6rKRxzv.js → importer-Bm1_5vPS.js} +781 -104
  16. package/dist/chunks/importer-Bm1_5vPS.js.map +1 -0
  17. package/dist/chunks/importer-ByPGO-09.js +1614 -0
  18. package/dist/chunks/importer-ByPGO-09.js.map +1 -0
  19. package/dist/chunks/importer-DOepkgnG.js +1713 -0
  20. package/dist/chunks/importer-DOepkgnG.js.map +1 -0
  21. package/dist/chunks/json-elements-CZY1wiZh.js +779 -0
  22. package/dist/chunks/json-elements-CZY1wiZh.js.map +1 -0
  23. package/dist/chunks/ontology-BnrJ4I98.js +113 -0
  24. package/dist/chunks/ontology-BnrJ4I98.js.map +1 -0
  25. package/dist/chunks/{records-DSpbTE5s.js → records-BzNicMsf.js} +2 -2
  26. package/dist/chunks/{records-DSpbTE5s.js.map → records-BzNicMsf.js.map} +1 -1
  27. package/dist/chunks/{writer-DHHfHn11.js → report-BcWboivV.js} +106 -928
  28. package/dist/chunks/report-BcWboivV.js.map +1 -0
  29. package/dist/chunks/weights-CwISIpCP.js +176 -0
  30. package/dist/chunks/weights-CwISIpCP.js.map +1 -0
  31. package/dist/chunks/writer-DQiKgQJc.js +656 -0
  32. package/dist/chunks/writer-DQiKgQJc.js.map +1 -0
  33. package/dist/csv.js +5 -3
  34. package/dist/csv.js.map +1 -1
  35. package/dist/cx.d.ts +1 -0
  36. package/dist/cx.js +6 -0
  37. package/dist/cx.js.map +1 -0
  38. package/dist/cx2.d.ts +1 -0
  39. package/dist/cx2.js +10 -0
  40. package/dist/cx2.js.map +1 -0
  41. package/dist/dot.js +1 -1
  42. package/dist/gexf.js +15 -3
  43. package/dist/gexf.js.map +1 -1
  44. package/dist/gml.js +5 -3
  45. package/dist/gml.js.map +1 -1
  46. package/dist/graph-io.js +158 -383
  47. package/dist/graph-io.js.map +1 -1
  48. package/dist/graphml.js +1 -1
  49. package/dist/json.js +1 -1
  50. package/dist/neo4j.js +5 -3
  51. package/dist/neo4j.js.map +1 -1
  52. package/dist/obo.d.ts +1 -0
  53. package/dist/obo.js +6 -0
  54. package/dist/obo.js.map +1 -0
  55. package/dist/pajek.js +1 -1
  56. package/dist/src/common/json-elements.d.ts +286 -0
  57. package/dist/src/common/json-elements.d.ts.map +1 -0
  58. package/dist/src/common/json-elements.js +926 -0
  59. package/dist/src/common/json-elements.js.map +1 -0
  60. package/dist/src/common/ontology.d.ts +59 -0
  61. package/dist/src/common/ontology.d.ts.map +1 -0
  62. package/dist/src/common/ontology.js +147 -0
  63. package/dist/src/common/ontology.js.map +1 -0
  64. package/dist/src/formats/cx/importer.d.ts +133 -0
  65. package/dist/src/formats/cx/importer.d.ts.map +1 -0
  66. package/dist/src/formats/cx/importer.js +2220 -0
  67. package/dist/src/formats/cx/importer.js.map +1 -0
  68. package/dist/src/formats/cx/index.d.ts +7 -0
  69. package/dist/src/formats/cx/index.d.ts.map +1 -0
  70. package/dist/src/formats/cx/index.js +7 -0
  71. package/dist/src/formats/cx/index.js.map +1 -0
  72. package/dist/src/formats/cx2/exporter.d.ts +59 -0
  73. package/dist/src/formats/cx2/exporter.d.ts.map +1 -0
  74. package/dist/src/formats/cx2/exporter.js +864 -0
  75. package/dist/src/formats/cx2/exporter.js.map +1 -0
  76. package/dist/src/formats/cx2/importer.d.ts +169 -0
  77. package/dist/src/formats/cx2/importer.d.ts.map +1 -0
  78. package/dist/src/formats/cx2/importer.js +1652 -0
  79. package/dist/src/formats/cx2/importer.js.map +1 -0
  80. package/dist/src/formats/cx2/index.d.ts +7 -0
  81. package/dist/src/formats/cx2/index.d.ts.map +1 -0
  82. package/dist/src/formats/cx2/index.js +7 -0
  83. package/dist/src/formats/cx2/index.js.map +1 -0
  84. package/dist/src/formats/gexf/exporter.d.ts +2 -0
  85. package/dist/src/formats/gexf/exporter.d.ts.map +1 -1
  86. package/dist/src/formats/gexf/exporter.js +6 -0
  87. package/dist/src/formats/gexf/exporter.js.map +1 -1
  88. package/dist/src/formats/gexf/importer.d.ts +2 -4
  89. package/dist/src/formats/gexf/importer.d.ts.map +1 -1
  90. package/dist/src/formats/gexf/importer.js +2 -4
  91. package/dist/src/formats/gexf/importer.js.map +1 -1
  92. package/dist/src/formats/gexf/index.d.ts +1 -1
  93. package/dist/src/formats/gexf/index.js +2 -2
  94. package/dist/src/formats/gexf/index.js.map +1 -1
  95. package/dist/src/formats/gml/exporter.js +1 -1
  96. package/dist/src/formats/gml/exporter.js.map +1 -1
  97. package/dist/src/formats/json/dialect.d.ts +8 -6
  98. package/dist/src/formats/json/dialect.d.ts.map +1 -1
  99. package/dist/src/formats/json/dialect.js +26 -3
  100. package/dist/src/formats/json/dialect.js.map +1 -1
  101. package/dist/src/formats/json/importer.d.ts +324 -7
  102. package/dist/src/formats/json/importer.d.ts.map +1 -1
  103. package/dist/src/formats/json/importer.js +159 -126
  104. package/dist/src/formats/json/importer.js.map +1 -1
  105. package/dist/src/formats/json/obographs.d.ts +21 -0
  106. package/dist/src/formats/json/obographs.d.ts.map +1 -0
  107. package/dist/src/formats/json/obographs.js +476 -0
  108. package/dist/src/formats/json/obographs.js.map +1 -0
  109. package/dist/src/formats/obo/importer.d.ts +90 -0
  110. package/dist/src/formats/obo/importer.d.ts.map +1 -0
  111. package/dist/src/formats/obo/importer.js +1248 -0
  112. package/dist/src/formats/obo/importer.js.map +1 -0
  113. package/dist/src/formats/obo/index.d.ts +7 -0
  114. package/dist/src/formats/obo/index.d.ts.map +1 -0
  115. package/dist/src/formats/obo/index.js +7 -0
  116. package/dist/src/formats/obo/index.js.map +1 -0
  117. package/dist/src/formats/obo/syntax.d.ts +121 -0
  118. package/dist/src/formats/obo/syntax.d.ts.map +1 -0
  119. package/dist/src/formats/obo/syntax.js +424 -0
  120. package/dist/src/formats/obo/syntax.js.map +1 -0
  121. package/dist/src/index.d.ts +3 -0
  122. package/dist/src/index.d.ts.map +1 -1
  123. package/dist/src/index.js +3 -0
  124. package/dist/src/index.js.map +1 -1
  125. package/dist/src/registry.d.ts.map +1 -1
  126. package/dist/src/registry.js +8 -1
  127. package/dist/src/registry.js.map +1 -1
  128. package/dist/src/sniff.d.ts +1 -1
  129. package/dist/src/sniff.d.ts.map +1 -1
  130. package/dist/src/sniff.js +25 -3
  131. package/dist/src/sniff.js.map +1 -1
  132. package/package.json +16 -1
  133. package/src/common/json-elements.ts +1147 -0
  134. package/src/common/ontology.ts +169 -0
  135. package/src/formats/cx/importer.ts +2733 -0
  136. package/src/formats/cx/index.ts +7 -0
  137. package/src/formats/cx2/exporter.ts +1036 -0
  138. package/src/formats/cx2/importer.ts +2187 -0
  139. package/src/formats/cx2/index.ts +7 -0
  140. package/src/formats/gexf/exporter.ts +11 -0
  141. package/src/formats/gexf/importer.ts +2 -2
  142. package/src/formats/gexf/index.ts +1 -1
  143. package/src/formats/gml/exporter.ts +1 -1
  144. package/src/formats/json/dialect.ts +37 -7
  145. package/src/formats/json/importer.ts +210 -132
  146. package/src/formats/json/obographs.ts +563 -0
  147. package/src/formats/obo/importer.ts +1695 -0
  148. package/src/formats/obo/index.ts +7 -0
  149. package/src/formats/obo/syntax.ts +466 -0
  150. package/src/index.ts +11 -0
  151. package/src/registry.ts +8 -1
  152. package/src/sniff.ts +48 -5
  153. package/dist/chunks/importer-B6rKRxzv.js.map +0 -1
  154. package/dist/chunks/writer-DHHfHn11.js.map +0 -1
@@ -0,0 +1,1248 @@
1
+ /**
2
+ * The OBO importer (design section 4; research-obo.md): the line-oriented OBO flat file format of
3
+ * the Gene Ontology and the OBO Foundry, versions 1.0, 1.2 and 1.4 read as their union (real files
4
+ * do not say which they follow: the GO writes `format-version: 1.2` and uses 1.4 tags), streamed
5
+ * through the common LineReader.
6
+ *
7
+ * `[Term]` and `[Instance]` frames are nodes; `is_a`, `relationship` and `instance_of` clauses are
8
+ * directed edges from the frame to the target, with the relation in a `relation` dict column (role
9
+ * `kind`) and the trailing qualifier block in a `qualifiers` json column. `[Typedef]` frames are
10
+ * metadata (`meta.extra.obo.typedefs`) unless `typedefs: "nodes"`. Every other tag fills the
11
+ * column of its name (src/common/ontology.ts); an unknown tag is kept in `obo.unrecognized`.
12
+ * Frames that share an id are merged (spec 4.1.1): list clauses take the union, a single-valued
13
+ * clause keeps the first. A target no frame declares becomes a placeholder node (the guide's
14
+ * recommended reading; ontologies point into their imports this way) unless `addMissingNodes` is
15
+ * false.
16
+ *
17
+ * ponytail: the frames are held until the end of the file (merging needs every frame of an id,
18
+ * dangling detection the whole id set), so memory is about twice the snapshot's; a two-pass reader
19
+ * would lift that for files beyond a few hundred megabytes.
20
+ */
21
+ import { GraphFormatError, INVALID_INDEX, } from "@graphty/graph-format";
22
+ import { declareResolved, RENAMED_CODE, ROLE_TAKEN_CODE } from "../../common/attributes.js";
23
+ import { BAD_VALUE_CODE, DANGLING_REFERENCE_CODE, DUPLICATE_ATTRIBUTE_CODE, DUPLICATE_NODE_CODE, EMPTY_INPUT_CODE, ENCODING_FALLBACK_CODE, INVALID_ENCODING_CODE, INVALID_UTF8_CODE, MISSING_ID_CODE, OPTION_IGNORED_CODE, UNKNOWN_ELEMENT_CODE, UNKNOWN_ENCODING_CODE, } from "../../common/codes.js";
24
+ import { DirectionResolver } from "../../common/direction.js";
25
+ import { ID_MERGED_CODE, IdCoercer } from "../../common/ids.js";
26
+ import { LineReader, throwIfAborted } from "../../common/input.js";
27
+ import { OBO_NODE_COLUMNS, oboColumnDecl, PLACEHOLDER_COLUMN, SYNONYM_SCOPES } from "../../common/ontology.js";
28
+ import { reportSinkOptions, reportUnusedOptions, resolveImportOptions, SINK_OPTION_CODE, } from "../../common/options.js";
29
+ import { ImportReportBuilder } from "../../common/report.js";
30
+ import { endsWithContinuation, hasStrayBrace, parseXref, parseXrefList, splitQualifiers, splitTagValue, stripComment, tokenize, unescapeObo, } from "./syntax.js";
31
+ /** Issue codes of the OBO importer (design section 4.3). */
32
+ export const OBO_ISSUE = Object.freeze({
33
+ /** The input is empty or whitespace (fatal). */
34
+ EMPTY_INPUT: EMPTY_INPUT_CODE,
35
+ /** The input holds invalid UTF-8 (fatal). */
36
+ INVALID_UTF8: INVALID_UTF8_CODE,
37
+ /** Invalid bytes in the encoding a BOM or the encoding option chose (fatal). */
38
+ INVALID_ENCODING: INVALID_ENCODING_CODE,
39
+ /** Bytes that are not UTF-8 were read as windows-1252. */
40
+ ENCODING_FALLBACK: ENCODING_FALLBACK_CODE,
41
+ /** A declared encoding the platform cannot decode was ignored. */
42
+ UNKNOWN_ENCODING: UNKNOWN_ENCODING_CODE,
43
+ /** A frame without an `id` clause; the frame is skipped. */
44
+ MISSING_ID: MISSING_ID_CODE,
45
+ /** A clause whose value cannot be read (a boolean other than true / false, a relationship with one or three values); the clause is skipped. */
46
+ BAD_VALUE: BAD_VALUE_CODE,
47
+ /** Two frames with one id were merged (spec 4.1.1). */
48
+ DUPLICATE_NODE: DUPLICATE_NODE_CODE,
49
+ /** A single-valued tag given twice for one id (a cardinality violation); the first is kept. */
50
+ DUPLICATE_ATTRIBUTE: DUPLICATE_ATTRIBUTE_CODE,
51
+ /** An unknown tag (kept in `obo.unrecognized`) or frame type (skipped), once per name. */
52
+ UNKNOWN_ELEMENT: UNKNOWN_ELEMENT_CODE,
53
+ /** A target no frame declares: a placeholder node was made, or the edge dropped under addMissingNodes false. */
54
+ DANGLING_REFERENCE: DANGLING_REFERENCE_CODE,
55
+ /** A line without a colon, an unterminated quote, a def without its xref list, a qualifier block that does not parse, an unescaped brace. */
56
+ SYNTAX: "W_OBO_SYNTAX",
57
+ /** The frame's `id` is not its first clause; it is used anyway. */
58
+ ID_NOT_FIRST: "W_OBO_ID_NOT_FIRST",
59
+ /** A synonym without a scope in a file that does not say 1.2, or with a scope that is not one of the four. */
60
+ SYNONYM_SCOPE: "W_OBO_SYNONYM_SCOPE",
61
+ /** A relation, subset or synonym type that nothing declares. */
62
+ UNDECLARED: "W_OBO_UNDECLARED",
63
+ /** One id for a Term and a Typedef (or an Instance); the Term (the first node frame) is the node. */
64
+ ID_KIND_CLASH: "W_OBO_ID_KIND_CLASH",
65
+ /** Fewer than two `intersection_of` or `union_of` clauses on a frame. */
66
+ CARDINALITY: "W_OBO_CARDINALITY",
67
+ /** An obsolete term with is_a / relationship, or replaced_by / consider on a term that is not obsolete. */
68
+ OBSOLETION: "W_OBO_OBSOLETION",
69
+ /** An OBO 1.0 / 1.2 tag read as its 1.4 meaning (exact_synonym, xref_analog, use_term, typeref, version). */
70
+ DEPRECATED_TAG: "W_OBO_DEPRECATED_TAG",
71
+ /** A backslash line continuation (deprecated in 1.4). */
72
+ DEPRECATED_SYNTAX: "W_OBO_DEPRECATED_SYNTAX",
73
+ /** A header clause kept in `meta.extra.obo.header` whose meaning is not applied (import, id-mapping, the treat-xrefs macros, owl-axioms). */
74
+ HEADER_NOT_APPLIED: "W_OBO_HEADER_NOT_APPLIED",
75
+ /** Obsolete terms and their edges left out under obsolete: "drop". */
76
+ OBSOLETE_DROPPED: "W_OBO_OBSOLETE_DROPPED",
77
+ /** Two distinct id texts became one number under ids "number". */
78
+ ID_MERGED: ID_MERGED_CODE,
79
+ /** A vocabulary column renamed `<name>#obo` because the sink already holds the name. */
80
+ COLUMN_RENAMED: RENAMED_CODE,
81
+ /** A vocabulary column declared without its role because the sink already holds it. */
82
+ ROLE_TAKEN: ROLE_TAKEN_CODE,
83
+ /** A common option the format has no use for (defaultDirected, weightFrom, nodeIdFrom, ...). */
84
+ OPTION_IGNORED: OPTION_IGNORED_CODE,
85
+ /** A builder-policy option the caller passed that the caller's sink does not use. */
86
+ SINK_OPTION: SINK_OPTION_CODE,
87
+ });
88
+ /** The common options the OBO importer reads. */
89
+ const USED_OPTIONS = new Set([
90
+ "ids",
91
+ "addMissingNodes",
92
+ "duplicateEdges",
93
+ "selfLoops",
94
+ "onMixedDirection",
95
+ "weightDtype",
96
+ "errorLimit",
97
+ "signal",
98
+ "onProgress",
99
+ "encoding",
100
+ ]);
101
+ const DEFAULTS = { ids: "keep", defaultDirected: true, weightFrom: null };
102
+ /** Frames between two checks of the cancellation signal. */
103
+ const ABORT_CHECK_INTERVAL = 64;
104
+ /** Bytes of the head sniff() reads. */
105
+ const SNIFF_BYTES = 4096;
106
+ const FRAME_KINDS = new Set(["Term", "Typedef", "Instance"]);
107
+ /** Header tags kept but not applied (design 4.3, W_OBO_HEADER_NOT_APPLIED). */
108
+ const NOT_APPLIED_HEADER = /^(import|typeref|id-mapping|default-relationship-id-prefix|owl-axioms|treat-xrefs-as-.*)$/;
109
+ /** Deprecated header tags and what they are now. */
110
+ const DEPRECATED_HEADER = new Map([
111
+ ["typeref", "import"],
112
+ ["version", "data-version"],
113
+ ]);
114
+ /** The 1.0 / 1.2 frame tags read as their 1.4 meaning: tag to [1.4 tag, synonym scope]. */
115
+ const DEPRECATED_TAGS = new Map([
116
+ ["exact_synonym", ["synonym", "EXACT"]],
117
+ ["narrow_synonym", ["synonym", "NARROW"]],
118
+ ["broad_synonym", ["synonym", "BROAD"]],
119
+ ["related_synonym", ["synonym", "RELATED"]],
120
+ ["xref_analog", ["xref", null]],
121
+ ["xref_unk", ["xref", null]],
122
+ ["xref_unknown", ["xref", null]],
123
+ ["use_term", ["consider", null]],
124
+ ]);
125
+ /** Relations every OBO file may use without a Typedef (the 1.2 built-ins). */
126
+ const BUILTIN_RELATIONS = new Set([
127
+ "is_a",
128
+ "instance_of",
129
+ "disjoint_from",
130
+ "inverse_of",
131
+ "union_of",
132
+ "intersection_of",
133
+ ]);
134
+ /** Single-valued text tags (first value kept). */
135
+ const TEXT_TAGS = new Set([
136
+ "name",
137
+ "namespace",
138
+ "comment",
139
+ "created_by",
140
+ "creation_date",
141
+ "domain",
142
+ "range",
143
+ "inverse_of",
144
+ ]);
145
+ /** Single-valued boolean tags. */
146
+ const BOOL_TAGS = new Set(Object.keys(OBO_NODE_COLUMNS).filter((name) => OBO_NODE_COLUMNS[name].dtype === "bool"));
147
+ /** Tags whose value is one id, collected in a list column. */
148
+ const ID_LIST_TAGS = new Set([
149
+ "alt_id",
150
+ "subset",
151
+ "replaced_by",
152
+ "consider",
153
+ "union_of",
154
+ "equivalent_to",
155
+ "disjoint_from",
156
+ "transitive_over",
157
+ "disjoint_over",
158
+ ]);
159
+ /** Tags that only a Typedef frame may carry (elsewhere they are unrecognized). */
160
+ const TYPEDEF_TAGS = new Set([
161
+ "domain",
162
+ "range",
163
+ "inverse_of",
164
+ "transitive_over",
165
+ "disjoint_over",
166
+ "holds_over_chain",
167
+ "equivalent_to_chain",
168
+ "expand_assertion_to",
169
+ "expand_expression_to",
170
+ "is_cyclic",
171
+ "is_reflexive",
172
+ "is_symmetric",
173
+ "is_anti_symmetric",
174
+ "is_asymmetric",
175
+ "is_transitive",
176
+ "is_functional",
177
+ "is_inverse_functional",
178
+ "is_metadata_tag",
179
+ "is_class_level",
180
+ ]);
181
+ /**
182
+ * Resolve the OBO-specific options.
183
+ * @param options - the caller's options
184
+ * @returns the resolved options; E_UNSUPPORTED for a value outside its set
185
+ */
186
+ function resolveOboOptions(options) {
187
+ const obsolete = options?.obsolete ?? "keep";
188
+ const typedefs = options?.typedefs ?? "metadata";
189
+ if (obsolete !== "keep" && obsolete !== "drop") {
190
+ throw unsupported("obsolete", obsolete, ["keep", "drop"]);
191
+ }
192
+ if (typedefs !== "metadata" && typedefs !== "nodes") {
193
+ throw unsupported("typedefs", typedefs, ["metadata", "nodes"]);
194
+ }
195
+ return { obsolete, typedefs };
196
+ }
197
+ /**
198
+ * The E_UNSUPPORTED error of an option outside its set.
199
+ * @param option - the option name
200
+ * @param found - the value
201
+ * @param supported - the allowed values
202
+ * @returns the error
203
+ */
204
+ function unsupported(option, found, supported) {
205
+ return new GraphFormatError("E_UNSUPPORTED", `option ${option}: ${JSON.stringify(found)} is not one of ${supported.join(", ")}`, {
206
+ option,
207
+ found,
208
+ supported,
209
+ });
210
+ }
211
+ /** One import's state. */
212
+ class OboReader {
213
+ /**
214
+ * Create a reader.
215
+ * @param report - the report
216
+ * @param signal - the cancellation signal
217
+ */
218
+ constructor(report, signal) {
219
+ /** Header clauses by tag, raw, in order. */
220
+ this.header = {};
221
+ this.version = null;
222
+ this.defaultNamespace = null;
223
+ this.subsets = new Set();
224
+ /** Declared synonym types and their default scope (null: none). */
225
+ this.synonymTypes = new Map();
226
+ /** Node frames (Term, Instance) by id, in first-appearance order. */
227
+ this.nodes = new Map();
228
+ /** Typedef frames by id. */
229
+ this.typedefs = new Map();
230
+ this.tallies = new Map();
231
+ /** The frame being read: its kind (null for an unknown frame type), name and clauses. */
232
+ this.frame = null;
233
+ /** The frames of an unknown type, raw, for `meta.extra.obo.unknownFrames`. */
234
+ this.unknownFrames = [];
235
+ this.framesSinceCheck = 0;
236
+ /** Whether any significant line was read. */
237
+ this.significant = false;
238
+ this.report = report;
239
+ this.signal = signal;
240
+ }
241
+ /**
242
+ * Read one logical line.
243
+ * @param text - the line, continuation joined
244
+ * @param line - its 1-based number
245
+ */
246
+ line(text, line) {
247
+ const t = text.trim();
248
+ if (t.length === 0) {
249
+ return;
250
+ }
251
+ this.significant = true;
252
+ if (t.startsWith("!")) {
253
+ return;
254
+ }
255
+ const header = /^\[([^\]]*)\]\s*(?:!.*)?$/.exec(t);
256
+ if (header !== null) {
257
+ this.startFrame(header[1].trim(), line);
258
+ return;
259
+ }
260
+ // the comment goes first, so a colon inside it never makes `foo ! a:b` a tag-value line
261
+ const tv = splitTagValue(stripComment(t));
262
+ if (tv === null) {
263
+ this.tally("parse-error", OBO_ISSUE.SYNTAX, "a line without a colon was skipped", "line", line);
264
+ return;
265
+ }
266
+ const clause = { tag: tv.tag, value: stripComment(tv.rest), line };
267
+ if (this.frame === null) {
268
+ this.headerClause(clause);
269
+ }
270
+ else {
271
+ this.frame.clauses.push(clause);
272
+ }
273
+ }
274
+ /**
275
+ * Start a frame, finishing the one before.
276
+ * @param name - the frame type
277
+ * @param line - the header's line
278
+ */
279
+ startFrame(name, line) {
280
+ this.finishFrame();
281
+ const kind = FRAME_KINDS.has(name) ? name : null;
282
+ if (kind === null) {
283
+ this.tally("validation-error", OBO_ISSUE.UNKNOWN_ELEMENT, "frames of an unknown type are not nodes; kept in meta.extra.obo.unknownFrames", `[${name}]`, line);
284
+ }
285
+ this.frame = { kind, name, line, clauses: [] };
286
+ }
287
+ /**
288
+ * Record one header clause.
289
+ * @param clause - the clause
290
+ */
291
+ headerClause(clause) {
292
+ var _a, _b;
293
+ ((_a = this.header)[_b = clause.tag] ?? (_a[_b] = [])).push(clause.value);
294
+ const { tag } = clause;
295
+ const tokens = tokenize(splitQualifiers(clause.value).value);
296
+ const first = tokens.length > 0 && tokens[0].kind === "word" ? tokens[0].text : null;
297
+ if (tag === "format-version") {
298
+ this.version ?? (this.version = unescapeObo(clause.value).trim());
299
+ }
300
+ else if (tag === "default-namespace") {
301
+ this.defaultNamespace ?? (this.defaultNamespace = first);
302
+ }
303
+ else if (tag === "subsetdef" && first !== null) {
304
+ this.subsets.add(first);
305
+ }
306
+ else if (tag === "synonymtypedef" && first !== null) {
307
+ const scope = tokens.slice(1).find((t) => t.kind === "word" && SYNONYM_SCOPES.has(t.text));
308
+ this.synonymTypes.set(first, scope?.text ?? null);
309
+ }
310
+ const now = DEPRECATED_HEADER.get(tag);
311
+ if (now !== undefined) {
312
+ this.tally("coercion", OBO_ISSUE.DEPRECATED_TAG, `the 1.0 header tag is read as ${now}`, tag, clause.line);
313
+ }
314
+ if (NOT_APPLIED_HEADER.test(tag)) {
315
+ this.tally("unsupported", OBO_ISSUE.HEADER_NOT_APPLIED, "kept in meta.extra.obo.header, not applied", tag, clause.line);
316
+ }
317
+ }
318
+ /** Finish the frame being read: find its id and merge its clauses into the record of that id. */
319
+ finishFrame() {
320
+ var _a, _b;
321
+ const { frame } = this;
322
+ this.frame = null;
323
+ if (frame === null) {
324
+ return;
325
+ }
326
+ if (frame.kind === null) {
327
+ // the guides: an unrecognized frame must survive, so its clauses are kept, raw
328
+ const clauses = {};
329
+ for (const clause of frame.clauses) {
330
+ (clauses[_a = clause.tag] ?? (clauses[_a] = [])).push(clause.value);
331
+ }
332
+ this.unknownFrames.push({ type: frame.name, clauses });
333
+ return;
334
+ }
335
+ if (++this.framesSinceCheck >= ABORT_CHECK_INTERVAL) {
336
+ this.framesSinceCheck = 0;
337
+ throwIfAborted(this.signal);
338
+ }
339
+ const idAt = frame.clauses.findIndex((c) => c.tag === "id");
340
+ const id = idAt < 0 ? "" : unescapeObo(splitQualifiers(frame.clauses[idAt].value).value).trim();
341
+ if (id.length === 0) {
342
+ this.report.error("missing-value", OBO_ISSUE.MISSING_ID, `a [${frame.kind}] frame has no id; it is skipped`, {
343
+ line: frame.line,
344
+ });
345
+ this.report.counts.skippedNodes++;
346
+ return;
347
+ }
348
+ if (idAt > 0) {
349
+ this.tally("validation-error", OBO_ISSUE.ID_NOT_FIRST, "the id clause is not the frame's first; it is used", "id", frame.clauses[idAt].line);
350
+ }
351
+ const record = this.recordFor(frame.kind, id, frame.line);
352
+ for (let i = 0; i < frame.clauses.length; i++) {
353
+ const clause = frame.clauses[i];
354
+ if (clause.tag === "id") {
355
+ if (i !== idAt) {
356
+ this.tally("validation-error", OBO_ISSUE.DUPLICATE_ATTRIBUTE, "a frame names a second id; the first is kept", "id", clause.line);
357
+ }
358
+ continue;
359
+ }
360
+ this.applyClause(record, clause);
361
+ }
362
+ if (record.kind === "Typedef") {
363
+ (_b = record.raw).id ?? (_b.id = [id]);
364
+ }
365
+ }
366
+ /**
367
+ * The record a frame merges into: the existing one of its id, or a new one.
368
+ * @param kind - the frame type
369
+ * @param id - the id
370
+ * @param line - the frame's line
371
+ * @returns the record
372
+ */
373
+ recordFor(kind, id, line) {
374
+ const map = kind === "Typedef" ? this.typedefs : this.nodes;
375
+ const existing = map.get(id);
376
+ if (existing !== undefined) {
377
+ if (existing.kind !== kind) {
378
+ this.tally("validation-error", OBO_ISSUE.ID_KIND_CLASH, `an id names both a ${existing.kind} and an ${kind}; the ${existing.kind} is the node`, id, line);
379
+ }
380
+ else {
381
+ this.report.warning("merged", OBO_ISSUE.DUPLICATE_NODE, `two [${kind}] frames have the id ${id}; they are merged (spec 4.1.1)`, {
382
+ line,
383
+ element: id,
384
+ });
385
+ }
386
+ return existing;
387
+ }
388
+ const record = {
389
+ id,
390
+ kind,
391
+ line,
392
+ values: new Map(),
393
+ lists: new Map(),
394
+ edges: [],
395
+ seen: new Set(),
396
+ raw: {},
397
+ };
398
+ map.set(id, record);
399
+ return record;
400
+ }
401
+ /**
402
+ * A clause's tag (a 1.0 tag read as its current one), the synonym scope that tag implies, its
403
+ * qualifier block and its value, with the syntax warnings about stray braces recorded.
404
+ * @param clause - the clause
405
+ * @returns the parts
406
+ */
407
+ clauseParts(clause) {
408
+ let { tag } = clause;
409
+ let scope = null;
410
+ const deprecated = DEPRECATED_TAGS.get(tag);
411
+ if (deprecated !== undefined) {
412
+ this.tally("coercion", OBO_ISSUE.DEPRECATED_TAG, `the 1.0 tag is read as ${deprecated[0]}`, tag, clause.line);
413
+ [tag, scope] = deprecated;
414
+ }
415
+ const split = splitQualifiers(clause.value);
416
+ const { qualifiers } = split;
417
+ if (split.badBlock !== null) {
418
+ this.tally("parse-error", OBO_ISSUE.SYNTAX, "a closing brace that is not a qualifier block was kept as text", tag, clause.line);
419
+ }
420
+ else if (hasStrayBrace(split.value)) {
421
+ this.tally("parse-error", OBO_ISSUE.SYNTAX, "an unescaped brace inside a value was kept as text", tag, clause.line);
422
+ }
423
+ return { tag, scope, qualifiers, value: split.value };
424
+ }
425
+ /**
426
+ * A def, expand_assertion_to or expand_expression_to clause: quoted text and its xref list.
427
+ * @param record - the record
428
+ * @param tag - the tag
429
+ * @param value - the clause value
430
+ * @param tokens - its tokens
431
+ * @param qualifiers - its qualifier block
432
+ * @param line - its line
433
+ */
434
+ definition(record, tag, value, tokens, qualifiers, line) {
435
+ if (tag === "def" && record.values.has("def")) {
436
+ // the second def of an id: reported, and none of its xrefs leak into the first's
437
+ this.single(record, "def", null, line);
438
+ return;
439
+ }
440
+ const text = this.quotedWithXrefs(record, tag, value, tokens, line);
441
+ if (tag === "def") {
442
+ this.single(record, "def", text.text, line, () => {
443
+ record.values.set("def.xrefs", text.xrefs);
444
+ });
445
+ this.extraQualifiers(record, tag, text.text, qualifiers);
446
+ }
447
+ else {
448
+ this.push(record, tag, withQualifiers({ template: text.text, xrefs: text.xrefs }, qualifiers));
449
+ }
450
+ }
451
+ /**
452
+ * Apply one clause to a record.
453
+ * @param record - the record
454
+ * @param clause - the clause
455
+ */
456
+ applyClause(record, clause) {
457
+ var _a, _b;
458
+ const key = `${clause.tag}\u0000${clause.value}`;
459
+ if (record.seen.has(key)) {
460
+ return;
461
+ }
462
+ record.seen.add(key);
463
+ if (record.kind === "Typedef") {
464
+ ((_a = record.raw)[_b = clause.tag] ?? (_a[_b] = [])).push(clause.value);
465
+ }
466
+ const { tag, scope, qualifiers, value } = this.clauseParts(clause);
467
+ const where = { line: clause.line, element: record.id };
468
+ if (TYPEDEF_TAGS.has(tag) && record.kind !== "Typedef") {
469
+ this.unrecognized(record, clause);
470
+ return;
471
+ }
472
+ if (TEXT_TAGS.has(tag)) {
473
+ this.single(record, tag, unescapeObo(value).trim(), clause.line);
474
+ this.extraQualifiers(record, tag, unescapeObo(value).trim(), qualifiers);
475
+ return;
476
+ }
477
+ if (BOOL_TAGS.has(tag)) {
478
+ const text = unescapeObo(value).trim();
479
+ if (text !== "true" && text !== "false") {
480
+ this.report.error("validation-error", OBO_ISSUE.BAD_VALUE, `${tag}: ${JSON.stringify(text)} is not true or false; the clause is skipped`, where);
481
+ return;
482
+ }
483
+ this.single(record, tag, text === "true", clause.line);
484
+ this.extraQualifiers(record, tag, text, qualifiers);
485
+ return;
486
+ }
487
+ const tokens = tokenize(value);
488
+ if (ID_LIST_TAGS.has(tag)) {
489
+ const id = this.oneWord(tokens, tag, where);
490
+ if (id !== null) {
491
+ this.push(record, tag, id);
492
+ this.extraQualifiers(record, tag, id, qualifiers);
493
+ if (tag === "subset" && !this.subsets.has(id)) {
494
+ this.tally("validation-error", OBO_ISSUE.UNDECLARED, "a subset no subsetdef declares", id, clause.line);
495
+ }
496
+ }
497
+ return;
498
+ }
499
+ switch (tag) {
500
+ case "is_a":
501
+ case "instance_of": {
502
+ if (tag === "instance_of" && record.kind !== "Instance") {
503
+ this.unrecognized(record, clause);
504
+ return;
505
+ }
506
+ const target = this.oneWord(tokens, tag, where);
507
+ if (target !== null) {
508
+ record.edges.push({ relation: tag, target, qualifiers, line: clause.line });
509
+ }
510
+ return;
511
+ }
512
+ case "relationship": {
513
+ if (tokens.length !== 2 || tokens.some((t) => t.kind !== "word")) {
514
+ this.report.error("validation-error", OBO_ISSUE.BAD_VALUE, `relationship needs a relation and a target, found ${JSON.stringify(unescapeObo(value))}; the clause is skipped`, where);
515
+ return;
516
+ }
517
+ record.edges.push({ relation: tokens[0].text, target: tokens[1].text, qualifiers, line: clause.line });
518
+ return;
519
+ }
520
+ case "intersection_of": {
521
+ if (tokens.length < 1 || tokens.length > 2 || tokens.some((t) => t.kind !== "word")) {
522
+ this.report.error("validation-error", OBO_ISSUE.BAD_VALUE, `intersection_of needs a class, or a relation and a class, found ${JSON.stringify(unescapeObo(value))}; the clause is skipped`, where);
523
+ return;
524
+ }
525
+ const item = tokens.length === 1
526
+ ? { relation: null, target: tokens[0].text }
527
+ : { relation: tokens[0].text, target: tokens[1].text };
528
+ this.push(record, tag, withQualifiers(item, qualifiers));
529
+ return;
530
+ }
531
+ case "holds_over_chain":
532
+ case "equivalent_to_chain": {
533
+ if (tokens.length !== 2 || tokens.some((t) => t.kind !== "word")) {
534
+ this.report.error("validation-error", OBO_ISSUE.BAD_VALUE, `${tag} needs two relations; the clause is skipped`, where);
535
+ return;
536
+ }
537
+ this.push(record, tag, [tokens[0].text, tokens[1].text]);
538
+ this.extraQualifiers(record, tag, `${tokens[0].text} ${tokens[1].text}`, qualifiers);
539
+ return;
540
+ }
541
+ case "def":
542
+ case "expand_assertion_to":
543
+ case "expand_expression_to":
544
+ this.definition(record, tag, value, tokens, qualifiers, clause.line);
545
+ return;
546
+ case "synonym":
547
+ this.synonym(record, tokens, scope, qualifiers, clause.line);
548
+ return;
549
+ case "xref": {
550
+ const xref = parseXref(value);
551
+ if (xref === null) {
552
+ this.report.error("validation-error", OBO_ISSUE.BAD_VALUE, "xref has no id; the clause is skipped", where);
553
+ return;
554
+ }
555
+ this.push(record, "xref", xref.id);
556
+ this.describe(record, [xref]);
557
+ this.extraQualifiers(record, tag, xref.id, qualifiers ?? xref.qualifiers);
558
+ return;
559
+ }
560
+ case "property_value":
561
+ this.propertyValue(record, tokens, qualifiers, where);
562
+ return;
563
+ default:
564
+ this.unrecognized(record, clause);
565
+ }
566
+ }
567
+ /**
568
+ * Read a value that must be one word.
569
+ * @param tokens - the value's tokens
570
+ * @param tag - the tag, for the message
571
+ * @param where - the location
572
+ * @returns the word, or null after reporting E_BAD_VALUE
573
+ */
574
+ oneWord(tokens, tag, where) {
575
+ if (tokens.length === 1 && tokens[0].kind === "word") {
576
+ return tokens[0].text;
577
+ }
578
+ const found = tokens.map((t) => t.text).join(" ");
579
+ this.report.error("validation-error", OBO_ISSUE.BAD_VALUE, `${tag} needs one id, found ${JSON.stringify(found)}; the clause is skipped`, where);
580
+ return null;
581
+ }
582
+ /**
583
+ * Read `"text" [xrefs]` (def, expand_*), reporting the forms the grammar does not allow.
584
+ * @param record - the record (xref descriptions go to its `xref.descriptions`)
585
+ * @param tag - the tag
586
+ * @param value - the raw value
587
+ * @param tokens - its tokens
588
+ * @param line - the line
589
+ * @returns the text and the xref ids
590
+ */
591
+ quotedWithXrefs(record, tag, value, tokens, line) {
592
+ if (tokens.length === 0 || tokens[0].kind !== "quoted") {
593
+ this.tally("parse-error", OBO_ISSUE.SYNTAX, "the text is not quoted; the whole value is kept", tag, line);
594
+ return { text: unescapeObo(value).trim(), xrefs: [] };
595
+ }
596
+ if (tokens[0].unterminated) {
597
+ this.tally("parse-error", OBO_ISSUE.SYNTAX, "an unterminated quote; the text runs to the end of the line", tag, line);
598
+ return { text: tokens[0].text, xrefs: [] };
599
+ }
600
+ const list = tokens[1];
601
+ if (list?.kind !== "list") {
602
+ this.tally("parse-error", OBO_ISSUE.SYNTAX, "the xref list is missing", tag, line);
603
+ return { text: tokens[0].text, xrefs: [] };
604
+ }
605
+ const xrefs = parseXrefList(list.text);
606
+ this.describe(record, xrefs);
607
+ this.xrefQualifiers(record, tag, xrefs);
608
+ return { text: tokens[0].text, xrefs: xrefs.map((x) => x.id) };
609
+ }
610
+ /**
611
+ * Read a synonym: `"text" SCOPE? TYPE? [xrefs]` (the scope optional in 1.2).
612
+ * @param record - the record
613
+ * @param tokens - the value's tokens
614
+ * @param fixedScope - the scope of a 1.0 tag (exact_synonym), or null
615
+ * @param qualifiers - the clause's qualifiers
616
+ * @param line - the line
617
+ */
618
+ synonym(record, tokens, fixedScope, qualifiers, line) {
619
+ if (tokens.length === 0 || tokens[0].kind !== "quoted") {
620
+ this.report.error("validation-error", OBO_ISSUE.BAD_VALUE, "a synonym needs its quoted text; the clause is skipped", {
621
+ line,
622
+ element: record.id,
623
+ });
624
+ return;
625
+ }
626
+ if (tokens[0].unterminated) {
627
+ this.tally("parse-error", OBO_ISSUE.SYNTAX, "an unterminated quote; the text runs to the end of the line", "synonym", line);
628
+ }
629
+ const words = tokens
630
+ .slice(1)
631
+ .filter((t) => t.kind === "word")
632
+ .map((t) => t.text);
633
+ const list = tokens.slice(1).find((t) => t.kind === "list");
634
+ let scope = fixedScope;
635
+ let type = null;
636
+ if (fixedScope === null) {
637
+ if (words.length > 0 && SYNONYM_SCOPES.has(words[0])) {
638
+ [scope] = words;
639
+ type = words[1] ?? null;
640
+ }
641
+ else if (words.length > 0 && this.synonymTypes.has(words[0])) {
642
+ [type] = words;
643
+ scope = "RELATED";
644
+ }
645
+ else if (words.length > 0) {
646
+ [type] = words;
647
+ this.tally("validation-error", OBO_ISSUE.SYNONYM_SCOPE, `a scope that is not EXACT, BROAD, NARROW or RELATED was kept as the type, scope null`, words[0], line);
648
+ }
649
+ else {
650
+ scope = "RELATED";
651
+ }
652
+ if (scope === "RELATED" && (words.length === 0 || words[0] === type) && !this.allowsMissingScope()) {
653
+ this.tally("validation-error", OBO_ISSUE.SYNONYM_SCOPE, "a synonym without a scope (1.2 only) is read as RELATED", "synonym", line);
654
+ }
655
+ }
656
+ if (type !== null) {
657
+ const declared = this.synonymTypes.get(type);
658
+ if (declared === undefined) {
659
+ if (scope !== null) {
660
+ this.tally("validation-error", OBO_ISSUE.UNDECLARED, "a synonym type no synonymtypedef declares", type, line);
661
+ }
662
+ }
663
+ else if (declared !== null) {
664
+ // the 1.2 guide: a type's default scope is used regardless of the synonym's own
665
+ scope = declared;
666
+ }
667
+ }
668
+ if (words.length > 2 || list === undefined) {
669
+ this.tally("parse-error", OBO_ISSUE.SYNTAX, list === undefined ? "a synonym without its xref list" : "extra words after the synonym type", "synonym", line);
670
+ }
671
+ const xrefs = list === undefined ? [] : parseXrefList(list.text);
672
+ this.describe(record, xrefs);
673
+ this.xrefQualifiers(record, "synonym", xrefs);
674
+ this.push(record, "synonym", withQualifiers({ text: tokens[0].text, scope, type, xrefs: xrefs.map((x) => x.id) }, qualifiers));
675
+ }
676
+ /**
677
+ * Whether the file's version lets a synonym omit its scope (1.0 and 1.2).
678
+ * @returns true for a 1.2 or 1.0 file
679
+ */
680
+ allowsMissingScope() {
681
+ return this.version !== null && /^(1\.[0-2]|GO_1\.[0-2])\b/.test(this.version);
682
+ }
683
+ /**
684
+ * Read a property_value: `relation "literal" datatype` or `relation id`.
685
+ * @param record - the record
686
+ * @param tokens - the value's tokens
687
+ * @param qualifiers - the clause's qualifiers
688
+ * @param where - the location
689
+ */
690
+ propertyValue(record, tokens, qualifiers, where) {
691
+ if (tokens.length < 2 || tokens.length > 3 || tokens[0].kind !== "word" || tokens[1].kind === "list") {
692
+ this.report.error("validation-error", OBO_ISSUE.BAD_VALUE, "property_value needs a relation and a value; the clause is skipped", where);
693
+ return;
694
+ }
695
+ const datatype = tokens.length === 3 ? tokens[2].text : null;
696
+ this.push(record, "property_value", withQualifiers({ relation: tokens[0].text, value: tokens[1].text, datatype }, qualifiers));
697
+ }
698
+ /**
699
+ * Set a single-valued column, keeping the first value of an id.
700
+ * @param record - the record
701
+ * @param column - the column
702
+ * @param value - the value
703
+ * @param line - the line
704
+ * @param also - more to set together with the value
705
+ */
706
+ single(record, column, value, line, also) {
707
+ if (record.values.has(column)) {
708
+ this.tally("validation-error", OBO_ISSUE.DUPLICATE_ATTRIBUTE, "a single-valued tag given twice for one id; the first is kept", column, line);
709
+ return;
710
+ }
711
+ record.values.set(column, value);
712
+ also?.();
713
+ }
714
+ /**
715
+ * Append to a list column, without repeats (merged frames take the union).
716
+ * @param record - the record
717
+ * @param column - the column
718
+ * @param value - the item
719
+ */
720
+ push(record, column, value) {
721
+ let list = record.lists.get(column);
722
+ if (list === undefined) {
723
+ list = [];
724
+ record.lists.set(column, list);
725
+ }
726
+ // objects are never repeated: identical clauses were already skipped
727
+ if (typeof value === "object" || !list.includes(value)) {
728
+ list.push(value);
729
+ }
730
+ }
731
+ /**
732
+ * Record xref descriptions in `xref.descriptions`.
733
+ * @param record - the record
734
+ * @param xrefs - the xrefs
735
+ */
736
+ describe(record, xrefs) {
737
+ var _a;
738
+ for (const xref of xrefs) {
739
+ if (xref.description !== null) {
740
+ const map = (record.values.get("xref.descriptions") ?? {});
741
+ map[_a = xref.id] ?? (map[_a] = xref.description);
742
+ record.values.set("xref.descriptions", map);
743
+ }
744
+ }
745
+ }
746
+ /**
747
+ * Keep the per-xref qualifiers of an xref list (OBO 1.2: `[A:1 {source="x"}]`) in
748
+ * `obo.qualifiers` under `<tag>.xrefs`.
749
+ * @param record - the record
750
+ * @param tag - the clause's tag
751
+ * @param xrefs - the list's xrefs
752
+ */
753
+ xrefQualifiers(record, tag, xrefs) {
754
+ for (const xref of xrefs) {
755
+ this.extraQualifiers(record, `${tag}.xrefs`, xref.id, xref.qualifiers);
756
+ }
757
+ }
758
+ /**
759
+ * Keep the qualifiers of a clause whose column has no place for them in `obo.qualifiers`.
760
+ * @param record - the record
761
+ * @param tag - the tag
762
+ * @param value - the clause's value as stored
763
+ * @param qualifiers - the qualifiers, or null
764
+ */
765
+ extraQualifiers(record, tag, value, qualifiers) {
766
+ if (qualifiers === null) {
767
+ return;
768
+ }
769
+ const map = (record.values.get("obo.qualifiers") ?? {});
770
+ (map[tag] ?? (map[tag] = [])).push({ value, qualifiers });
771
+ record.values.set("obo.qualifiers", map);
772
+ }
773
+ /**
774
+ * Keep an unknown clause in `obo.unrecognized`.
775
+ * @param record - the record
776
+ * @param clause - the clause
777
+ */
778
+ unrecognized(record, clause) {
779
+ var _a;
780
+ const map = (record.values.get("obo.unrecognized") ?? {});
781
+ (map[_a = clause.tag] ?? (map[_a] = [])).push(unescapeObo(clause.value));
782
+ record.values.set("obo.unrecognized", map);
783
+ this.tally("validation-error", OBO_ISSUE.UNKNOWN_ELEMENT, "an unknown tag was kept in obo.unrecognized", clause.tag, clause.line);
784
+ }
785
+ /**
786
+ * Count a warning, recorded once per code and element at the end.
787
+ * @param category - the category
788
+ * @param code - the code
789
+ * @param what - what happened
790
+ * @param element - the tag, id or name it is about
791
+ * @param line - the first line
792
+ */
793
+ tally(category, code, what, element, line) {
794
+ const key = `${code}\u0000${element}\u0000${what}`;
795
+ const found = this.tallies.get(key);
796
+ if (found === undefined) {
797
+ this.tallies.set(key, { category, code, element, what, line, count: 1 });
798
+ }
799
+ else {
800
+ found.count++;
801
+ }
802
+ }
803
+ /** Record the counted warnings, in the order they were first seen. */
804
+ flushTallies() {
805
+ for (const t of this.tallies.values()) {
806
+ this.report.warning(t.category, t.code, `${t.element}: ${t.what} (${t.count} time(s), first at line ${t.line})`, {
807
+ line: t.line,
808
+ element: t.element,
809
+ });
810
+ }
811
+ this.tallies.clear();
812
+ }
813
+ /**
814
+ * The header as GraphMeta fields (design 4.2): name from `ontology`, the version, `created` from
815
+ * the `dd:MM:yyyy HH:mm` date, `creator` from `saved-by`.
816
+ * @returns the metadata patch, without `extra`
817
+ */
818
+ meta() {
819
+ const first = (tag) => {
820
+ const values = this.header[tag];
821
+ return values === undefined ? null : unescapeObo(values[0]).trim();
822
+ };
823
+ return {
824
+ name: first("ontology"),
825
+ sourceFormat: "obo",
826
+ sourceVersion: this.version,
827
+ created: oboDate(first("date")),
828
+ creator: first("saved-by"),
829
+ };
830
+ }
831
+ /**
832
+ * The raw header clauses.
833
+ * @returns the header by tag
834
+ */
835
+ headerRecord() {
836
+ return this.header;
837
+ }
838
+ /**
839
+ * The namespace a frame without one gets.
840
+ * @returns the default namespace, or null
841
+ */
842
+ namespaceDefault() {
843
+ return this.defaultNamespace;
844
+ }
845
+ }
846
+ /**
847
+ * Add the qualifiers to a json item when there are any.
848
+ * @param item - the item
849
+ * @param qualifiers - the qualifiers, or null
850
+ * @returns the item
851
+ */
852
+ function withQualifiers(item, qualifiers) {
853
+ return qualifiers === null ? item : { ...item, qualifiers };
854
+ }
855
+ /**
856
+ * The OBO header date `dd:MM:yyyy HH:mm` as ISO 8601 (`yyyy-MM-ddTHH:mm`).
857
+ * @param text - the header value, or null
858
+ * @returns the ISO text, or null when the date is not of that form
859
+ */
860
+ function oboDate(text) {
861
+ const m = text === null ? null : /^(\d{2}):(\d{2}):(\d{4})\s+(\d{2}):(\d{2})$/.exec(text);
862
+ if (m === null) {
863
+ return null;
864
+ }
865
+ const [, dd, mm, yyyy, hh, min] = m;
866
+ if (Number(mm) < 1 || Number(mm) > 12 || Number(dd) < 1 || Number(dd) > 31 || Number(hh) > 23 || Number(min) > 59) {
867
+ return null;
868
+ }
869
+ return `${yyyy}-${mm}-${dd}T${hh}:${min}`;
870
+ }
871
+ /**
872
+ * Decide what is written (design 4.2): the node records (Typedefs too under typedefs "nodes", the
873
+ * obsolete ones left out under obsolete "drop"), the placeholders of undeclared targets and the
874
+ * edges; record the warnings that need the whole file (obsoletion rules, undeclared relations,
875
+ * dangling targets, dropped obsolete terms).
876
+ * @param reader - the finished reader
877
+ * @param report - the report
878
+ * @param addMissingNodes - whether an undeclared target becomes a placeholder node
879
+ * @param obo - the OBO options
880
+ * @returns the plan
881
+ */
882
+ function planGraph(reader, report, addMissingNodes, obo) {
883
+ const records = [...reader.nodes.values()];
884
+ for (const [id, typedef] of reader.typedefs) {
885
+ const node = reader.nodes.get(id);
886
+ if (node !== undefined) {
887
+ reader.tally("validation-error", OBO_ISSUE.ID_KIND_CLASH, `an id names both a ${node.kind} and a Typedef; the ${node.kind} is the node`, id, typedef.line);
888
+ }
889
+ else if (obo.typedefs === "nodes") {
890
+ records.push(typedef);
891
+ }
892
+ }
893
+ // a class expression needs two or more operands, counted after merging (spec 4.1.1)
894
+ for (const record of [...reader.nodes.values(), ...reader.typedefs.values()]) {
895
+ for (const tag of ["intersection_of", "union_of"]) {
896
+ if (record.lists.get(tag)?.length === 1) {
897
+ reader.tally("validation-error", OBO_ISSUE.CARDINALITY, "a single clause (a class expression needs two or more)", tag, record.line);
898
+ }
899
+ }
900
+ }
901
+ const dropped = new Set();
902
+ const kept = new Set();
903
+ for (const record of records) {
904
+ const obsolete = record.values.get("is_obsolete") === true;
905
+ (obo.obsolete === "drop" && obsolete ? dropped : kept).add(record.id);
906
+ // obsoletion rules (the 1.2 guide; fastobo-validator --obsoletion)
907
+ if (obsolete && record.edges.length > 0) {
908
+ reader.tally("validation-error", OBO_ISSUE.OBSOLETION, "an obsolete term has is_a or relationship clauses; they are kept", "is_obsolete", record.edges[0].line);
909
+ }
910
+ if (!obsolete && (record.lists.has("replaced_by") || record.lists.has("consider"))) {
911
+ reader.tally("validation-error", OBO_ISSUE.OBSOLETION, "replaced_by or consider on a term that is not obsolete; kept", "replaced_by", record.line);
912
+ }
913
+ }
914
+ const declared = new Set(reader.typedefs.keys());
915
+ for (const typedef of reader.typedefs.values()) {
916
+ for (const xref of (typedef.lists.get("xref") ?? [])) {
917
+ declared.add(xref);
918
+ }
919
+ }
920
+ const dangling = [];
921
+ const danglingSet = new Set();
922
+ let danglingEdges = 0;
923
+ let droppedEdges = 0;
924
+ const edges = [];
925
+ for (const record of records) {
926
+ for (const edge of record.edges) {
927
+ if (dropped.has(record.id) || dropped.has(edge.target)) {
928
+ droppedEdges++;
929
+ continue;
930
+ }
931
+ if (!BUILTIN_RELATIONS.has(edge.relation) && !declared.has(edge.relation)) {
932
+ reader.tally("validation-error", OBO_ISSUE.UNDECLARED, "a relation no Typedef declares", edge.relation, edge.line);
933
+ }
934
+ if (!kept.has(edge.target)) {
935
+ if (!danglingSet.has(edge.target)) {
936
+ danglingSet.add(edge.target);
937
+ dangling.push(edge.target);
938
+ }
939
+ if (!addMissingNodes) {
940
+ danglingEdges++;
941
+ report.counts.skippedEdges++;
942
+ continue;
943
+ }
944
+ }
945
+ edges.push({ source: record.id, edge });
946
+ }
947
+ }
948
+ reader.flushTallies();
949
+ reportDangling(records, dangling, addMissingNodes, danglingEdges, report);
950
+ if (dropped.size > 0) {
951
+ report.warning("merged", OBO_ISSUE.OBSOLETE_DROPPED, `${dropped.size} obsolete term(s) and ${droppedEdges} edge(s) to or from them were left out (obsolete: "drop")`, { element: [...dropped][0] });
952
+ }
953
+ return {
954
+ nodes: records.filter((record) => kept.has(record.id)),
955
+ placeholders: addMissingNodes ? dangling : [],
956
+ edges,
957
+ };
958
+ }
959
+ /**
960
+ * Record the one W_DANGLING_REFERENCE of an import: the count, the first targets, and for a target
961
+ * that is another term's alt_id, the term it is an alt_id of.
962
+ * @param records - every node record
963
+ * @param dangling - the undeclared targets, in first-reference order
964
+ * @param addMissingNodes - whether they became placeholder nodes
965
+ * @param droppedEdges - the edges dropped under addMissingNodes false
966
+ * @param report - the report
967
+ */
968
+ function reportDangling(records, dangling, addMissingNodes, droppedEdges, report) {
969
+ if (dangling.length === 0) {
970
+ return;
971
+ }
972
+ const altOf = new Map();
973
+ for (const record of records) {
974
+ for (const alt of (record.lists.get("alt_id") ?? [])) {
975
+ altOf.set(alt, record.id);
976
+ }
977
+ }
978
+ const shown = dangling.slice(0, 5).map((id) => {
979
+ const primary = altOf.get(id);
980
+ return primary === undefined ? id : `${id} (an alt_id of ${primary})`;
981
+ });
982
+ const more = dangling.length > 5 ? `, ... (${dangling.length - 5} more)` : "";
983
+ const action = addMissingNodes
984
+ ? "each became a placeholder node (graphty.placeholder)"
985
+ : `${droppedEdges} edge(s) to them were dropped (addMissingNodes false)`;
986
+ report.warning("validation-error", OBO_ISSUE.DANGLING_REFERENCE, `${dangling.length} target(s) no frame declares: ${shown.join(", ")}${more}; ${action}`, { element: dangling[0] });
987
+ }
988
+ /** Pushes ids into a sink: the id coercion rule and the cancellation check. */
989
+ class Pusher {
990
+ /**
991
+ * Create a pusher.
992
+ * @param sink - the sink
993
+ * @param report - the report
994
+ * @param options - the resolved common options
995
+ */
996
+ constructor(sink, report, options) {
997
+ this.since = 0;
998
+ this.sink = sink;
999
+ this.report = report;
1000
+ this.coercer = new IdCoercer(options.ids);
1001
+ this.signal = options.signal;
1002
+ }
1003
+ /**
1004
+ * An id text under the `ids` rule, reporting a merge.
1005
+ * @param text - the id as written
1006
+ * @returns the node id
1007
+ */
1008
+ id(text) {
1009
+ const id = this.coercer.text(text);
1010
+ const merge = this.coercer.lastMerge;
1011
+ if (merge !== null) {
1012
+ this.report.warning("coercion", OBO_ISSUE.ID_MERGED, `id text ${JSON.stringify(merge.text)} merged with ${JSON.stringify(merge.previousText)} as ${merge.id}`, { element: text });
1013
+ }
1014
+ return id;
1015
+ }
1016
+ /**
1017
+ * Add a node, counting it, or record why it was refused.
1018
+ * @param text - the id as written
1019
+ * @param line - the line, when known
1020
+ * @returns the node index, or -1 when refused
1021
+ */
1022
+ node(text, line) {
1023
+ this.tick();
1024
+ try {
1025
+ const id = this.id(text);
1026
+ const known = this.sink.indexOf(id) !== INVALID_INDEX;
1027
+ const index = this.sink.addNode(id);
1028
+ if (!known) {
1029
+ this.report.counts.nodes++;
1030
+ }
1031
+ return index;
1032
+ }
1033
+ catch (err) {
1034
+ this.report.recordError(err, { line, element: text });
1035
+ this.report.counts.skippedNodes++;
1036
+ return -1;
1037
+ }
1038
+ }
1039
+ /** Check the cancellation signal every ABORT_CHECK_INTERVAL pushes. */
1040
+ tick() {
1041
+ if (++this.since >= ABORT_CHECK_INTERVAL) {
1042
+ this.since = 0;
1043
+ throwIfAborted(this.signal);
1044
+ }
1045
+ }
1046
+ }
1047
+ /**
1048
+ * Write the records into the sink (design 4.2): the nodes with the vocabulary columns they use,
1049
+ * the placeholders of undeclared targets, then the edges.
1050
+ * @param reader - the finished reader
1051
+ * @param sink - the sink
1052
+ * @param report - the report
1053
+ * @param options - the resolved options
1054
+ * @param obo - the OBO options
1055
+ */
1056
+ function writeGraph(reader, sink, report, options, obo) {
1057
+ const plan = planGraph(reader, report, options.addMissingNodes, obo);
1058
+ const push = new Pusher(sink, report, options);
1059
+ const namespace = reader.namespaceDefault();
1060
+ // columns, declared only when used, in the vocabulary's order
1061
+ const used = new Set(["type"]);
1062
+ for (const record of plan.nodes) {
1063
+ for (const name of [...record.values.keys(), ...record.lists.keys()]) {
1064
+ used.add(name);
1065
+ }
1066
+ }
1067
+ if (namespace !== null) {
1068
+ used.add("namespace");
1069
+ }
1070
+ const columns = new Map();
1071
+ for (const name of plan.nodes.length > 0 ? Object.keys(OBO_NODE_COLUMNS) : []) {
1072
+ if (used.has(name)) {
1073
+ columns.set(name, declareResolved(sink, "node", oboColumnDecl("node", name), report).handle);
1074
+ }
1075
+ }
1076
+ sink.reserve(plan.nodes.length + plan.placeholders.length, plan.edges.length);
1077
+ for (const record of plan.nodes) {
1078
+ const index = push.node(record.id, record.line);
1079
+ if (index < 0) {
1080
+ continue;
1081
+ }
1082
+ const cells = [["type", record.kind], ...record.values, ...record.lists];
1083
+ if (namespace !== null && !record.values.has("namespace")) {
1084
+ cells.push(["namespace", namespace]);
1085
+ }
1086
+ for (const [name, value] of cells) {
1087
+ const handle = columns.get(name);
1088
+ if (handle !== undefined) {
1089
+ sink.setNodeValue(handle, index, value);
1090
+ }
1091
+ }
1092
+ }
1093
+ if (plan.placeholders.length > 0) {
1094
+ const placeholder = declareResolved(sink, "node", oboColumnDecl("node", PLACEHOLDER_COLUMN), report).handle;
1095
+ for (const text of plan.placeholders) {
1096
+ const index = push.node(text);
1097
+ if (index >= 0) {
1098
+ sink.setNodeValue(placeholder, index, true);
1099
+ }
1100
+ }
1101
+ }
1102
+ writeEdges(plan, push, options);
1103
+ }
1104
+ /**
1105
+ * Push the planned edges through the direction resolver, with their relation and qualifiers.
1106
+ * @param plan - the plan
1107
+ * @param push - the pusher
1108
+ * @param options - the resolved options
1109
+ */
1110
+ function writeEdges(plan, push, options) {
1111
+ const { sink, report } = push;
1112
+ const direction = new DirectionResolver(sink, report, options.onMixedDirection);
1113
+ direction.setHeader(true);
1114
+ if (plan.edges.length === 0) {
1115
+ return;
1116
+ }
1117
+ const relation = declareResolved(sink, "edge", oboColumnDecl("edge", "relation"), report).handle;
1118
+ const qualifiers = plan.edges.some((e) => e.edge.qualifiers !== null)
1119
+ ? declareResolved(sink, "edge", oboColumnDecl("edge", "qualifiers"), report).handle
1120
+ : null;
1121
+ for (const { source, edge } of plan.edges) {
1122
+ push.tick();
1123
+ const before = sink.edgeCount;
1124
+ try {
1125
+ const e = direction.addEdge(push.id(source), push.id(edge.target), "directed", undefined, {
1126
+ line: edge.line,
1127
+ element: source,
1128
+ });
1129
+ report.counts.edges += sink.edgeCount - before;
1130
+ if (e < 0) {
1131
+ continue;
1132
+ }
1133
+ sink.setEdgeValue(relation, e, edge.relation);
1134
+ if (qualifiers !== null && edge.qualifiers !== null) {
1135
+ sink.setEdgeValue(qualifiers, e, edge.qualifiers);
1136
+ }
1137
+ }
1138
+ catch (err) {
1139
+ report.recordError(err, { line: edge.line, element: source });
1140
+ report.counts.skippedEdges++;
1141
+ }
1142
+ }
1143
+ }
1144
+ /**
1145
+ * Confidence that a head is OBO (design 1.5): after a BOM, blank lines and `!` comment lines, 0.9
1146
+ * when the first significant line is `format-version:` or a `[Term]` / `[Typedef]` / `[Instance]`
1147
+ * header, or such a header follows `tag: value` lines; 0.4 for a head of only `tag: value` lines.
1148
+ * @param head - the first bytes
1149
+ * @returns the confidence
1150
+ */
1151
+ function sniffObo(head) {
1152
+ let text = new TextDecoder("utf-8").decode(head.subarray(0, SNIFF_BYTES));
1153
+ if (text.charCodeAt(0) === 0xfeff) {
1154
+ text = text.slice(1);
1155
+ }
1156
+ const lines = text.split(/\r\n|\r|\n|\f/);
1157
+ // the last line may be cut mid-way
1158
+ if (lines.length > 1 && head.byteLength >= SNIFF_BYTES) {
1159
+ lines.pop();
1160
+ }
1161
+ let tagLines = 0;
1162
+ for (const raw of lines) {
1163
+ const line = raw.trim();
1164
+ if (line.length === 0 || line.startsWith("!")) {
1165
+ continue;
1166
+ }
1167
+ if (/^\[(Term|Typedef|Instance)\]\s*(!.*)?$/.test(line)) {
1168
+ return 0.9;
1169
+ }
1170
+ if (tagLines === 0 && /^format-version\s*:/.test(line)) {
1171
+ return 0.9;
1172
+ }
1173
+ if (/^[A-Za-z][A-Za-z0-9_-]*\s*:(\s|$)/.test(line)) {
1174
+ tagLines++;
1175
+ continue;
1176
+ }
1177
+ return 0;
1178
+ }
1179
+ return tagLines > 0 ? 0.4 : 0;
1180
+ }
1181
+ /**
1182
+ * The OBO importer plugin (design section 4).
1183
+ */
1184
+ export const oboImporter = Object.freeze({
1185
+ format: "obo",
1186
+ extensions: Object.freeze([".obo"]),
1187
+ mimeTypes: Object.freeze(["text/obo", "application/obo"]),
1188
+ /**
1189
+ * Confidence that the head is an OBO file.
1190
+ * @param head - the first bytes
1191
+ * @returns the confidence
1192
+ */
1193
+ sniff(head) {
1194
+ return sniffObo(head);
1195
+ },
1196
+ /**
1197
+ * Read an OBO file into the sink.
1198
+ * @param input - the text, bytes or stream
1199
+ * @param sink - the sink
1200
+ * @param options - format-specific and common options
1201
+ * @returns the import report; ImportError on a fatal error or beyond the error limit
1202
+ */
1203
+ async import(input, sink, options) {
1204
+ const resolved = resolveImportOptions(options, DEFAULTS);
1205
+ const obo = resolveOboOptions(options);
1206
+ const report = new ImportReportBuilder("obo", resolved.errorLimit);
1207
+ reportSinkOptions(sink, options, report, true);
1208
+ reportUnusedOptions(options, report, USED_OPTIONS);
1209
+ const reader = new OboReader(report, resolved.signal);
1210
+ const lines = new LineReader(input, report, resolved);
1211
+ let pending = null;
1212
+ for await (const physical of lines) {
1213
+ const { line } = lines;
1214
+ const text = pending === null ? physical : pending + physical;
1215
+ pending = null;
1216
+ if (endsWithContinuation(text)) {
1217
+ reader.tally("coercion", OBO_ISSUE.DEPRECATED_SYNTAX, "a backslash line continuation was joined (deprecated in 1.4)", "\\", line);
1218
+ pending = text.slice(0, -1);
1219
+ continue;
1220
+ }
1221
+ // the 1.4 grammar counts form feed as a line end; LineReader does not
1222
+ for (const piece of text.includes("\f") ? text.split("\f") : [text]) {
1223
+ reader.line(piece, line);
1224
+ }
1225
+ }
1226
+ if (pending !== null) {
1227
+ reader.line(pending, lines.line);
1228
+ }
1229
+ reader.finishFrame();
1230
+ if (!reader.significant) {
1231
+ report.fail(OBO_ISSUE.EMPTY_INPUT, "the input is empty");
1232
+ }
1233
+ throwIfAborted(resolved.signal);
1234
+ writeGraph(reader, sink, report, resolved, obo);
1235
+ const typedefs = {};
1236
+ for (const [id, record] of reader.typedefs) {
1237
+ typedefs[id] = record.raw;
1238
+ }
1239
+ const extra = { header: reader.headerRecord(), typedefs };
1240
+ if (reader.unknownFrames.length > 0) {
1241
+ extra.unknownFrames = reader.unknownFrames;
1242
+ }
1243
+ sink.setMeta({ ...reader.meta(), extra: { obo: extra } });
1244
+ throwIfAborted(resolved.signal);
1245
+ return report.finish();
1246
+ },
1247
+ });
1248
+ //# sourceMappingURL=importer.js.map