@graphty/graph-io 0.0.0 → 0.2.0
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/LICENSE +21 -0
- package/README.md +250 -28
- package/dist/chunks/children-CL3Cy0ez.js +238 -0
- package/dist/chunks/children-CL3Cy0ez.js.map +1 -0
- package/dist/chunks/escape-DyI8JofU.js +938 -0
- package/dist/chunks/escape-DyI8JofU.js.map +1 -0
- package/dist/chunks/importer-CQnJuWJw.js +2987 -0
- package/dist/chunks/importer-CQnJuWJw.js.map +1 -0
- package/dist/chunks/importer-CpCpfbxr.js +2015 -0
- package/dist/chunks/importer-CpCpfbxr.js.map +1 -0
- package/dist/chunks/importer-DbnGYr3_.js +2342 -0
- package/dist/chunks/importer-DbnGYr3_.js.map +1 -0
- package/dist/chunks/importer-GozH8DkN.js +3050 -0
- package/dist/chunks/importer-GozH8DkN.js.map +1 -0
- package/dist/chunks/records-CGpxszm1.js +605 -0
- package/dist/chunks/records-CGpxszm1.js.map +1 -0
- package/dist/chunks/text-CajMdVFy.js +189 -0
- package/dist/chunks/text-CajMdVFy.js.map +1 -0
- package/dist/chunks/writer-DxSKC7TL.js +2842 -0
- package/dist/chunks/writer-DxSKC7TL.js.map +1 -0
- package/dist/csv.d.ts +1 -0
- package/dist/csv.js +1702 -0
- package/dist/csv.js.map +1 -0
- package/dist/dot.d.ts +1 -0
- package/dist/dot.js +8 -0
- package/dist/dot.js.map +1 -0
- package/dist/gexf.d.ts +1 -0
- package/dist/gexf.js +3466 -0
- package/dist/gexf.js.map +1 -0
- package/dist/gml.d.ts +1 -0
- package/dist/gml.js +2647 -0
- package/dist/gml.js.map +1 -0
- package/dist/graph-io.d.ts +1 -0
- package/dist/graph-io.js +790 -0
- package/dist/graph-io.js.map +1 -0
- package/dist/graphml.d.ts +1 -0
- package/dist/graphml.js +8 -0
- package/dist/graphml.js.map +1 -0
- package/dist/json.d.ts +1 -0
- package/dist/json.js +11 -0
- package/dist/json.js.map +1 -0
- package/dist/neo4j.d.ts +1 -0
- package/dist/neo4j.js +2046 -0
- package/dist/neo4j.js.map +1 -0
- package/dist/pajek.d.ts +1 -0
- package/dist/pajek.js +8 -0
- package/dist/pajek.js.map +1 -0
- package/dist/src/children.d.ts +134 -0
- package/dist/src/children.d.ts.map +1 -0
- package/dist/src/children.js +274 -0
- package/dist/src/children.js.map +1 -0
- package/dist/src/common/attributes.d.ts +229 -0
- package/dist/src/common/attributes.d.ts.map +1 -0
- package/dist/src/common/attributes.js +368 -0
- package/dist/src/common/attributes.js.map +1 -0
- package/dist/src/common/codes.d.ts +105 -0
- package/dist/src/common/codes.d.ts.map +1 -0
- package/dist/src/common/codes.js +107 -0
- package/dist/src/common/codes.js.map +1 -0
- package/dist/src/common/declared-types.d.ts +84 -0
- package/dist/src/common/declared-types.d.ts.map +1 -0
- package/dist/src/common/declared-types.js +326 -0
- package/dist/src/common/declared-types.js.map +1 -0
- package/dist/src/common/direction.d.ts +206 -0
- package/dist/src/common/direction.d.ts.map +1 -0
- package/dist/src/common/direction.js +370 -0
- package/dist/src/common/direction.js.map +1 -0
- package/dist/src/common/escape.d.ts +92 -0
- package/dist/src/common/escape.d.ts.map +1 -0
- package/dist/src/common/escape.js +212 -0
- package/dist/src/common/escape.js.map +1 -0
- package/dist/src/common/export.d.ts +249 -0
- package/dist/src/common/export.d.ts.map +1 -0
- package/dist/src/common/export.js +594 -0
- package/dist/src/common/export.js.map +1 -0
- package/dist/src/common/format.d.ts +59 -0
- package/dist/src/common/format.d.ts.map +1 -0
- package/dist/src/common/format.js +106 -0
- package/dist/src/common/format.js.map +1 -0
- package/dist/src/common/ids.d.ts +83 -0
- package/dist/src/common/ids.d.ts.map +1 -0
- package/dist/src/common/ids.js +158 -0
- package/dist/src/common/ids.js.map +1 -0
- package/dist/src/common/input.d.ts +100 -0
- package/dist/src/common/input.d.ts.map +1 -0
- package/dist/src/common/input.js +335 -0
- package/dist/src/common/input.js.map +1 -0
- package/dist/src/common/lists.d.ts +34 -0
- package/dist/src/common/lists.d.ts.map +1 -0
- package/dist/src/common/lists.js +185 -0
- package/dist/src/common/lists.js.map +1 -0
- package/dist/src/common/options.d.ts +108 -0
- package/dist/src/common/options.d.ts.map +1 -0
- package/dist/src/common/options.js +265 -0
- package/dist/src/common/options.js.map +1 -0
- package/dist/src/common/report.d.ts +187 -0
- package/dist/src/common/report.d.ts.map +1 -0
- package/dist/src/common/report.js +274 -0
- package/dist/src/common/report.js.map +1 -0
- package/dist/src/common/temporal.d.ts +71 -0
- package/dist/src/common/temporal.d.ts.map +1 -0
- package/dist/src/common/temporal.js +266 -0
- package/dist/src/common/temporal.js.map +1 -0
- package/dist/src/common/text.d.ts +104 -0
- package/dist/src/common/text.d.ts.map +1 -0
- package/dist/src/common/text.js +255 -0
- package/dist/src/common/text.js.map +1 -0
- package/dist/src/common/weights.d.ts +77 -0
- package/dist/src/common/weights.d.ts.map +1 -0
- package/dist/src/common/weights.js +156 -0
- package/dist/src/common/weights.js.map +1 -0
- package/dist/src/common/writer.d.ts +51 -0
- package/dist/src/common/writer.d.ts.map +1 -0
- package/dist/src/common/writer.js +108 -0
- package/dist/src/common/writer.js.map +1 -0
- package/dist/src/common/xml.d.ts +245 -0
- package/dist/src/common/xml.d.ts.map +1 -0
- package/dist/src/common/xml.js +942 -0
- package/dist/src/common/xml.js.map +1 -0
- package/dist/src/formats/csv/exporter.d.ts +70 -0
- package/dist/src/formats/csv/exporter.d.ts.map +1 -0
- package/dist/src/formats/csv/exporter.js +682 -0
- package/dist/src/formats/csv/exporter.js.map +1 -0
- package/dist/src/formats/csv/header.d.ts +66 -0
- package/dist/src/formats/csv/header.d.ts.map +1 -0
- package/dist/src/formats/csv/header.js +152 -0
- package/dist/src/formats/csv/header.js.map +1 -0
- package/dist/src/formats/csv/importer.d.ts +82 -0
- package/dist/src/formats/csv/importer.d.ts.map +1 -0
- package/dist/src/formats/csv/importer.js +849 -0
- package/dist/src/formats/csv/importer.js.map +1 -0
- package/dist/src/formats/csv/index.d.ts +60 -0
- package/dist/src/formats/csv/index.d.ts.map +1 -0
- package/dist/src/formats/csv/index.js +63 -0
- package/dist/src/formats/csv/index.js.map +1 -0
- package/dist/src/formats/csv/records.d.ts +188 -0
- package/dist/src/formats/csv/records.d.ts.map +1 -0
- package/dist/src/formats/csv/records.js +702 -0
- package/dist/src/formats/csv/records.js.map +1 -0
- package/dist/src/formats/csv/values.d.ts +105 -0
- package/dist/src/formats/csv/values.d.ts.map +1 -0
- package/dist/src/formats/csv/values.js +192 -0
- package/dist/src/formats/csv/values.js.map +1 -0
- package/dist/src/formats/dot/exporter.d.ts +52 -0
- package/dist/src/formats/dot/exporter.d.ts.map +1 -0
- package/dist/src/formats/dot/exporter.js +836 -0
- package/dist/src/formats/dot/exporter.js.map +1 -0
- package/dist/src/formats/dot/importer.d.ts +102 -0
- package/dist/src/formats/dot/importer.d.ts.map +1 -0
- package/dist/src/formats/dot/importer.js +1291 -0
- package/dist/src/formats/dot/importer.js.map +1 -0
- package/dist/src/formats/dot/index.d.ts +7 -0
- package/dist/src/formats/dot/index.d.ts.map +1 -0
- package/dist/src/formats/dot/index.js +7 -0
- package/dist/src/formats/dot/index.js.map +1 -0
- package/dist/src/formats/dot/names.d.ts +29 -0
- package/dist/src/formats/dot/names.d.ts.map +1 -0
- package/dist/src/formats/dot/names.js +28 -0
- package/dist/src/formats/dot/names.js.map +1 -0
- package/dist/src/formats/dot/tokenizer.d.ts +114 -0
- package/dist/src/formats/dot/tokenizer.d.ts.map +1 -0
- package/dist/src/formats/dot/tokenizer.js +341 -0
- package/dist/src/formats/dot/tokenizer.js.map +1 -0
- package/dist/src/formats/gexf/exporter.d.ts +56 -0
- package/dist/src/formats/gexf/exporter.d.ts.map +1 -0
- package/dist/src/formats/gexf/exporter.js +1395 -0
- package/dist/src/formats/gexf/exporter.js.map +1 -0
- package/dist/src/formats/gexf/importer.d.ts +73 -0
- package/dist/src/formats/gexf/importer.d.ts.map +1 -0
- package/dist/src/formats/gexf/importer.js +1880 -0
- package/dist/src/formats/gexf/importer.js.map +1 -0
- package/dist/src/formats/gexf/index.d.ts +96 -0
- package/dist/src/formats/gexf/index.d.ts.map +1 -0
- package/dist/src/formats/gexf/index.js +97 -0
- package/dist/src/formats/gexf/index.js.map +1 -0
- package/dist/src/formats/gexf/schema.d.ts +135 -0
- package/dist/src/formats/gexf/schema.d.ts.map +1 -0
- package/dist/src/formats/gexf/schema.js +323 -0
- package/dist/src/formats/gexf/schema.js.map +1 -0
- package/dist/src/formats/gml/exporter.d.ts +69 -0
- package/dist/src/formats/gml/exporter.d.ts.map +1 -0
- package/dist/src/formats/gml/exporter.js +1093 -0
- package/dist/src/formats/gml/exporter.js.map +1 -0
- package/dist/src/formats/gml/importer.d.ts +66 -0
- package/dist/src/formats/gml/importer.d.ts.map +1 -0
- package/dist/src/formats/gml/importer.js +1331 -0
- package/dist/src/formats/gml/importer.js.map +1 -0
- package/dist/src/formats/gml/index.d.ts +85 -0
- package/dist/src/formats/gml/index.d.ts.map +1 -0
- package/dist/src/formats/gml/index.js +88 -0
- package/dist/src/formats/gml/index.js.map +1 -0
- package/dist/src/formats/gml/syntax.d.ts +186 -0
- package/dist/src/formats/gml/syntax.d.ts.map +1 -0
- package/dist/src/formats/gml/syntax.js +467 -0
- package/dist/src/formats/gml/syntax.js.map +1 -0
- package/dist/src/formats/graphml/constants.d.ts +169 -0
- package/dist/src/formats/graphml/constants.d.ts.map +1 -0
- package/dist/src/formats/graphml/constants.js +165 -0
- package/dist/src/formats/graphml/constants.js.map +1 -0
- package/dist/src/formats/graphml/exporter.d.ts +34 -0
- package/dist/src/formats/graphml/exporter.d.ts.map +1 -0
- package/dist/src/formats/graphml/exporter.js +1176 -0
- package/dist/src/formats/graphml/exporter.js.map +1 -0
- package/dist/src/formats/graphml/importer.d.ts +31 -0
- package/dist/src/formats/graphml/importer.d.ts.map +1 -0
- package/dist/src/formats/graphml/importer.js +1607 -0
- package/dist/src/formats/graphml/importer.js.map +1 -0
- package/dist/src/formats/graphml/index.d.ts +8 -0
- package/dist/src/formats/graphml/index.d.ts.map +1 -0
- package/dist/src/formats/graphml/index.js +8 -0
- package/dist/src/formats/graphml/index.js.map +1 -0
- package/dist/src/formats/graphml/tree.d.ts +72 -0
- package/dist/src/formats/graphml/tree.d.ts.map +1 -0
- package/dist/src/formats/graphml/tree.js +290 -0
- package/dist/src/formats/graphml/tree.js.map +1 -0
- package/dist/src/formats/json/dialect.d.ts +125 -0
- package/dist/src/formats/json/dialect.d.ts.map +1 -0
- package/dist/src/formats/json/dialect.js +262 -0
- package/dist/src/formats/json/dialect.js.map +1 -0
- package/dist/src/formats/json/exporter.d.ts +89 -0
- package/dist/src/formats/json/exporter.d.ts.map +1 -0
- package/dist/src/formats/json/exporter.js +1358 -0
- package/dist/src/formats/json/exporter.js.map +1 -0
- package/dist/src/formats/json/importer.d.ts +108 -0
- package/dist/src/formats/json/importer.d.ts.map +1 -0
- package/dist/src/formats/json/importer.js +1838 -0
- package/dist/src/formats/json/importer.js.map +1 -0
- package/dist/src/formats/json/index.d.ts +8 -0
- package/dist/src/formats/json/index.d.ts.map +1 -0
- package/dist/src/formats/json/index.js +8 -0
- package/dist/src/formats/json/index.js.map +1 -0
- package/dist/src/formats/neo4j/exporter.d.ts +68 -0
- package/dist/src/formats/neo4j/exporter.d.ts.map +1 -0
- package/dist/src/formats/neo4j/exporter.js +1055 -0
- package/dist/src/formats/neo4j/exporter.js.map +1 -0
- package/dist/src/formats/neo4j/header.d.ts +52 -0
- package/dist/src/formats/neo4j/header.d.ts.map +1 -0
- package/dist/src/formats/neo4j/header.js +131 -0
- package/dist/src/formats/neo4j/header.js.map +1 -0
- package/dist/src/formats/neo4j/importer.d.ts +73 -0
- package/dist/src/formats/neo4j/importer.d.ts.map +1 -0
- package/dist/src/formats/neo4j/importer.js +932 -0
- package/dist/src/formats/neo4j/importer.js.map +1 -0
- package/dist/src/formats/neo4j/index.d.ts +79 -0
- package/dist/src/formats/neo4j/index.d.ts.map +1 -0
- package/dist/src/formats/neo4j/index.js +83 -0
- package/dist/src/formats/neo4j/index.js.map +1 -0
- package/dist/src/formats/pajek/exporter.d.ts +58 -0
- package/dist/src/formats/pajek/exporter.d.ts.map +1 -0
- package/dist/src/formats/pajek/exporter.js +825 -0
- package/dist/src/formats/pajek/exporter.js.map +1 -0
- package/dist/src/formats/pajek/importer.d.ts +88 -0
- package/dist/src/formats/pajek/importer.d.ts.map +1 -0
- package/dist/src/formats/pajek/importer.js +1047 -0
- package/dist/src/formats/pajek/importer.js.map +1 -0
- package/dist/src/formats/pajek/index.d.ts +7 -0
- package/dist/src/formats/pajek/index.d.ts.map +1 -0
- package/dist/src/formats/pajek/index.js +7 -0
- package/dist/src/formats/pajek/index.js.map +1 -0
- package/dist/src/formats/pajek/syntax.d.ts +112 -0
- package/dist/src/formats/pajek/syntax.d.ts.map +1 -0
- package/dist/src/formats/pajek/syntax.js +269 -0
- package/dist/src/formats/pajek/syntax.js.map +1 -0
- package/dist/src/index.d.ts +35 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +39 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/registry.d.ts +207 -0
- package/dist/src/registry.d.ts.map +1 -0
- package/dist/src/registry.js +481 -0
- package/dist/src/registry.js.map +1 -0
- package/dist/src/sniff.d.ts +104 -0
- package/dist/src/sniff.d.ts.map +1 -0
- package/dist/src/sniff.js +357 -0
- package/dist/src/sniff.js.map +1 -0
- package/dist/src/types.d.ts +238 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +29 -0
- package/dist/src/types.js.map +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/package.json +122 -7
- package/src/children.ts +335 -0
- package/src/common/attributes.ts +520 -0
- package/src/common/codes.ts +153 -0
- package/src/common/declared-types.ts +374 -0
- package/src/common/direction.ts +518 -0
- package/src/common/escape.ts +231 -0
- package/src/common/export.ts +817 -0
- package/src/common/format.ts +111 -0
- package/src/common/ids.ts +176 -0
- package/src/common/input.ts +378 -0
- package/src/common/lists.ts +196 -0
- package/src/common/options.ts +377 -0
- package/src/common/report.ts +352 -0
- package/src/common/temporal.ts +302 -0
- package/src/common/text.ts +294 -0
- package/src/common/weights.ts +202 -0
- package/src/common/writer.ts +123 -0
- package/src/common/xml.ts +1053 -0
- package/src/formats/csv/exporter.ts +894 -0
- package/src/formats/csv/header.ts +172 -0
- package/src/formats/csv/importer.ts +1104 -0
- package/src/formats/csv/index.ts +88 -0
- package/src/formats/csv/records.ts +813 -0
- package/src/formats/csv/values.ts +224 -0
- package/src/formats/dot/exporter.ts +1014 -0
- package/src/formats/dot/importer.ts +1549 -0
- package/src/formats/dot/index.ts +7 -0
- package/src/formats/dot/names.ts +40 -0
- package/src/formats/dot/tokenizer.ts +384 -0
- package/src/formats/gexf/exporter.ts +1696 -0
- package/src/formats/gexf/importer.ts +2333 -0
- package/src/formats/gexf/index.ts +142 -0
- package/src/formats/gexf/schema.ts +361 -0
- package/src/formats/gml/exporter.ts +1404 -0
- package/src/formats/gml/importer.ts +1591 -0
- package/src/formats/gml/index.ts +128 -0
- package/src/formats/gml/syntax.ts +545 -0
- package/src/formats/graphml/constants.ts +225 -0
- package/src/formats/graphml/exporter.ts +1458 -0
- package/src/formats/graphml/importer.ts +2027 -0
- package/src/formats/graphml/index.ts +8 -0
- package/src/formats/graphml/tree.ts +318 -0
- package/src/formats/json/dialect.ts +317 -0
- package/src/formats/json/exporter.ts +1616 -0
- package/src/formats/json/importer.ts +2271 -0
- package/src/formats/json/index.ts +8 -0
- package/src/formats/neo4j/exporter.ts +1287 -0
- package/src/formats/neo4j/header.ts +156 -0
- package/src/formats/neo4j/importer.ts +1220 -0
- package/src/formats/neo4j/index.ts +116 -0
- package/src/formats/pajek/exporter.ts +1000 -0
- package/src/formats/pajek/importer.ts +1311 -0
- package/src/formats/pajek/index.ts +7 -0
- package/src/formats/pajek/syntax.ts +307 -0
- package/src/index.ts +244 -0
- package/src/registry.ts +617 -0
- package/src/sniff.ts +397 -0
- package/src/types.ts +262 -0
|
@@ -0,0 +1,1549 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DOT / Graphviz importer (design sections 8.4, 4.1, 5.1; research note 07 section 2.4). A
|
|
3
|
+
* recursive-descent parser over the DOT grammar pushes nodes and edges into the sink as they are
|
|
4
|
+
* mentioned, in first-mention order:
|
|
5
|
+
*
|
|
6
|
+
* - `graph` / `digraph` set the direction through the DirectionResolver (rule 1 of 8.4); `strict`
|
|
7
|
+
* merges parallel edges as cgraph does and is recorded in `meta.extra.dot.strict`; the graph name
|
|
8
|
+
* becomes `meta.name`.
|
|
9
|
+
* - node, edge and graph attribute statements (`node [..]`, `edge [..]`, `graph [..]`, `ID = ID`)
|
|
10
|
+
* are stateful, scoped defaults applied to the elements created after them in the same or a
|
|
11
|
+
* nested subgraph; every element receives its effective attributes as plain cells, so the
|
|
12
|
+
* exporter writes them back explicitly.
|
|
13
|
+
* - edge chains `a -> b -> c` and subgraph endpoints `{a b} -> c` expand to one edge per pair, in
|
|
14
|
+
* cgraph order; a port on an edge endpoint is kept in the `graphty.sourcePort` /
|
|
15
|
+
* `graphty.targetPort` edge columns (roles sourcePort / targetPort) and never part of the id; a
|
|
16
|
+
* port on a node statement has no meaning and is dropped with a warning.
|
|
17
|
+
* - a subgraph named `cluster*` (or carrying `cluster=true`) becomes a container NODE whose id is
|
|
18
|
+
* the cluster name (design section 5.10: containment is the `parent` role, never an adjacency
|
|
19
|
+
* edge); members get `graphty.parent` = the container's index, the container's own attributes are
|
|
20
|
+
* its node cells, and `graphty.cluster` = true marks it. A plain node and a cluster of the same
|
|
21
|
+
* name (fdp's cluster edges) merge into that one node with a warning. Other subgraphs are
|
|
22
|
+
* transparent grouping; their attributes are reported as dropped.
|
|
23
|
+
* - attribute values are ID strings inferred per column by the sink under the 5.1 grammar, except
|
|
24
|
+
* `label` (text, role label), `pos` on a node (the position role column, f32 x3, design section
|
|
25
|
+
* 5.2), `weight` (THE weight, `weightFrom`) and `key` (cgraph's edge identity, the edge id role).
|
|
26
|
+
* - ids are coerced with the common rule (`canonical` by default: `1` and `"1"` are the same node,
|
|
27
|
+
* as the DOT grammar says).
|
|
28
|
+
*
|
|
29
|
+
* The whole text is read first (design section 8.4 allows it for DOT). A grammar violation is
|
|
30
|
+
* fatal, as it is for Graphviz itself: the import aborts with ImportError (code E_DOT_SYNTAX)
|
|
31
|
+
* carrying the partial report. Errors the sink raises for one element are recorded and the element
|
|
32
|
+
* is skipped (section 8.6).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
type ColumnDecl,
|
|
37
|
+
type ColumnHandle,
|
|
38
|
+
GraphFormatError,
|
|
39
|
+
type GraphSink,
|
|
40
|
+
INVALID_INDEX,
|
|
41
|
+
type NodeId,
|
|
42
|
+
} from "@graphty/graph-format";
|
|
43
|
+
|
|
44
|
+
import { declareResolved } from "../../common/attributes.js";
|
|
45
|
+
import {
|
|
46
|
+
COLUMN_RENAMED_CODE,
|
|
47
|
+
DIRECTION_FORCED_CODE,
|
|
48
|
+
DIRECTION_REFUSED_CODE,
|
|
49
|
+
EMPTY_INPUT_CODE,
|
|
50
|
+
ID_MERGED_CODE,
|
|
51
|
+
INVALID_UTF8_CODE,
|
|
52
|
+
MIXED_DIRECTION_CODE,
|
|
53
|
+
MULTIPLE_GRAPHS_CODE,
|
|
54
|
+
OPTION_IGNORED_CODE,
|
|
55
|
+
ROLE_TAKEN_CODE,
|
|
56
|
+
SINK_OPTION_CODE,
|
|
57
|
+
SYNTAX_CODE,
|
|
58
|
+
} from "../../common/codes.js";
|
|
59
|
+
import { DirectionResolver, type EdgeKind } from "../../common/direction.js";
|
|
60
|
+
import { IdCoercer } from "../../common/ids.js";
|
|
61
|
+
import { readText, throwIfAborted } from "../../common/input.js";
|
|
62
|
+
import {
|
|
63
|
+
reportSinkOptions,
|
|
64
|
+
reportUnusedOptions,
|
|
65
|
+
type ResolvedImportOptions,
|
|
66
|
+
resolveImportOptions,
|
|
67
|
+
} from "../../common/options.js";
|
|
68
|
+
import { ImportReportBuilder } from "../../common/report.js";
|
|
69
|
+
import { parseTextCell, TextCellWriter, WIDENING_UNSUPPORTED_CODE } from "../../common/text.js";
|
|
70
|
+
import { parseWeightText } from "../../common/weights.js";
|
|
71
|
+
import { type CommonImportOptions, type GraphImporter, type ImportInput, type ImportReport } from "../../types.js";
|
|
72
|
+
import {
|
|
73
|
+
CLUSTER_COLUMN,
|
|
74
|
+
DOT_FORMAT,
|
|
75
|
+
DOT_ORIGIN,
|
|
76
|
+
KEY_ATTRIBUTE,
|
|
77
|
+
LABEL_ATTRIBUTE,
|
|
78
|
+
PARENT_COLUMN,
|
|
79
|
+
PIN_ATTRIBUTE,
|
|
80
|
+
POS_ATTRIBUTE,
|
|
81
|
+
SOURCE_PORT_COLUMN,
|
|
82
|
+
TARGET_PORT_COLUMN,
|
|
83
|
+
} from "./names.js";
|
|
84
|
+
import { DotSyntaxError, type DotToken, DotTokenizer } from "./tokenizer.js";
|
|
85
|
+
|
|
86
|
+
/** The DOT importer's format-specific options. */
|
|
87
|
+
export interface DotImportOptions {
|
|
88
|
+
/**
|
|
89
|
+
* What an edge operator that contradicts the graph keyword means (`--` in a digraph, `->` in a
|
|
90
|
+
* graph; a syntax error for Graphviz): "operator" (default) reads the edge with the operator's
|
|
91
|
+
* direction and resolves it per onMixedDirection, with a warning; "header" reads it with the
|
|
92
|
+
* graph's direction, with a warning; "error" aborts the import as Graphviz does.
|
|
93
|
+
*/
|
|
94
|
+
mismatchedEdgeOperator?: "operator" | "header" | "error" | undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The issue codes the DOT importer records (design section 8.6), by name: the codes shared with
|
|
99
|
+
* the other importers (src/common/codes.ts) and the DOT-specific ones. A key is the code without
|
|
100
|
+
* its severity and format prefixes.
|
|
101
|
+
*/
|
|
102
|
+
export const DOT_ISSUE = Object.freeze({
|
|
103
|
+
/** A grammar violation; fatal. */
|
|
104
|
+
SYNTAX: SYNTAX_CODE,
|
|
105
|
+
/** The input holds no graph at all (empty or only comments); fatal. */
|
|
106
|
+
EMPTY_INPUT: EMPTY_INPUT_CODE,
|
|
107
|
+
/** The input holds invalid UTF-8 (fatal). */
|
|
108
|
+
INVALID_UTF8: INVALID_UTF8_CODE,
|
|
109
|
+
/** Subgraphs or braces nested deeper than the parser's limit; fatal. */
|
|
110
|
+
NESTING: "E_DOT_NESTING",
|
|
111
|
+
/** An edge operator contradicting the graph keyword (warning under "operator" / "header"). */
|
|
112
|
+
EDGE_OPERATOR: "W_DOT_EDGE_OPERATOR",
|
|
113
|
+
/** A second graph in the same input; only the first is read. */
|
|
114
|
+
MULTIPLE_GRAPHS: MULTIPLE_GRAPHS_CODE,
|
|
115
|
+
/** A badly delimited numeral (`1e3`) split into two tokens, as Graphviz does with a warning. */
|
|
116
|
+
NUMERAL_AMBIGUITY: "W_DOT_NUMERAL_AMBIGUITY",
|
|
117
|
+
/** Attributes of a subgraph that is not a cluster (rank=same and the like) cannot be represented. */
|
|
118
|
+
SUBGRAPH_ATTRIBUTES_DROPPED: "W_DOT_SUBGRAPH_ATTRIBUTES_DROPPED",
|
|
119
|
+
/** A port on a node statement has no meaning and was dropped. */
|
|
120
|
+
NODE_PORT_DROPPED: "W_DOT_NODE_PORT_DROPPED",
|
|
121
|
+
/** A plain node and a cluster share a name and were merged into one container node. */
|
|
122
|
+
CLUSTER_NODE_MERGED: "W_DOT_CLUSTER_NODE_MERGED",
|
|
123
|
+
/** A node mentioned in two unrelated clusters keeps the first. */
|
|
124
|
+
CLUSTER_CONFLICT: "W_DOT_CLUSTER_CONFLICT",
|
|
125
|
+
/** A node `pos` that is not a point; the value was dropped. */
|
|
126
|
+
BAD_POS: "W_DOT_BAD_POS",
|
|
127
|
+
/** A parallel edge merged into an earlier one under `strict`. */
|
|
128
|
+
STRICT_MERGED: "W_DOT_STRICT_MERGED",
|
|
129
|
+
/** An edge merged into an earlier one with the same endpoints and `key`. */
|
|
130
|
+
KEY_MERGED: "W_DOT_KEY_MERGED",
|
|
131
|
+
/** A role (label, id, position, ...) was already taken in the caller's sink; the column was declared without it. */
|
|
132
|
+
ROLE_TAKEN: ROLE_TAKEN_CODE,
|
|
133
|
+
/** A column of another shape exists in the caller's sink under a name the importer declares; renamed `<name>#<id>`. */
|
|
134
|
+
COLUMN_RENAMED: COLUMN_RENAMED_CODE,
|
|
135
|
+
/** A common option the format has no use for was given a non-default value. */
|
|
136
|
+
OPTION_IGNORED: OPTION_IGNORED_CODE,
|
|
137
|
+
/** Two distinct id texts merged under ids: "number". */
|
|
138
|
+
ID_MERGED: ID_MERGED_CODE,
|
|
139
|
+
/** A builder-policy option the sink does not honour. */
|
|
140
|
+
SINK_OPTION: SINK_OPTION_CODE,
|
|
141
|
+
/** The sink refused the file's direction. */
|
|
142
|
+
DIRECTION_REFUSED: DIRECTION_REFUSED_CODE,
|
|
143
|
+
/** Edges forced to the policy's direction. */
|
|
144
|
+
DIRECTION_FORCED: DIRECTION_FORCED_CODE,
|
|
145
|
+
/** A mixed file under onMixedDirection "error" (fatal). */
|
|
146
|
+
MIXED_DIRECTION: MIXED_DIRECTION_CODE,
|
|
147
|
+
/** A text column the sink could not widen to the dtype its cells imply. */
|
|
148
|
+
WIDENING_UNSUPPORTED: WIDENING_UNSUPPORTED_CODE,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
/** The common options the DOT importer reads (the rest is reported by reportUnusedOptions). */
|
|
152
|
+
const USED_OPTIONS: ReadonlySet<keyof CommonImportOptions> = new Set<keyof CommonImportOptions>([
|
|
153
|
+
"ids",
|
|
154
|
+
"addMissingNodes",
|
|
155
|
+
"duplicateEdges",
|
|
156
|
+
"selfLoops",
|
|
157
|
+
"onMixedDirection",
|
|
158
|
+
"weightFrom",
|
|
159
|
+
"weightDtype",
|
|
160
|
+
"errorLimit",
|
|
161
|
+
"signal",
|
|
162
|
+
"onProgress",
|
|
163
|
+
]);
|
|
164
|
+
|
|
165
|
+
/** The deepest nesting of subgraphs and braces the parser accepts (each level is one stack frame). */
|
|
166
|
+
const MAX_NESTING = 1024;
|
|
167
|
+
|
|
168
|
+
const EXTENSIONS: readonly string[] = Object.freeze([".dot", ".gv"]);
|
|
169
|
+
const MIME_TYPES: readonly string[] = Object.freeze(["text/vnd.graphviz"]);
|
|
170
|
+
const CLUSTER_PREFIX = "cluster";
|
|
171
|
+
const CLUSTER_ATTRIBUTE = "cluster";
|
|
172
|
+
const STATEMENTS_PER_ABORT_CHECK = 64;
|
|
173
|
+
const MAX_ANCESTOR_WALK = 4096;
|
|
174
|
+
|
|
175
|
+
const DOT_HEADER = /^\s*(strict\s+)?(di)?graph\b/i;
|
|
176
|
+
const TRUE_TEXTS: ReadonlySet<string> = new Set(["true", "yes", "1"]);
|
|
177
|
+
const POINT_TEXT =
|
|
178
|
+
/^\s*([-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?)\s*,\s*([-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?)(?:\s*,\s*([-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?))?\s*(!?)\s*$/;
|
|
179
|
+
|
|
180
|
+
/** An attribute as written: name, value text and the line of the assignment. */
|
|
181
|
+
interface DotAttribute {
|
|
182
|
+
readonly name: string;
|
|
183
|
+
readonly value: string;
|
|
184
|
+
readonly line: number;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Where an edge issue is recorded: the statement's line and the edge's description. */
|
|
188
|
+
interface EdgeWhere {
|
|
189
|
+
readonly line: number;
|
|
190
|
+
readonly element: string;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** One endpoint of an edge chain: a mentioned node and its port. */
|
|
194
|
+
interface Endpoint {
|
|
195
|
+
readonly id: NodeId;
|
|
196
|
+
readonly index: number;
|
|
197
|
+
readonly port: string | null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** A lexical scope: the root graph or one subgraph. */
|
|
201
|
+
interface Scope {
|
|
202
|
+
readonly root: boolean;
|
|
203
|
+
/** The subgraph name, null for the root and for an anonymous subgraph. */
|
|
204
|
+
readonly name: string | null;
|
|
205
|
+
readonly line: number;
|
|
206
|
+
/** Defaults for nodes created in this scope, copied from the enclosing scope on entry. */
|
|
207
|
+
readonly nodeDefaults: Map<string, string>;
|
|
208
|
+
/** Defaults for edges created in this scope. */
|
|
209
|
+
readonly edgeDefaults: Map<string, string>;
|
|
210
|
+
/** Node indices mentioned in this scope (nested scopes included), in first-mention order. */
|
|
211
|
+
readonly members: number[];
|
|
212
|
+
readonly memberSet: Set<number>;
|
|
213
|
+
/** Container nodes of clusters closed inside this scope. */
|
|
214
|
+
readonly containers: number[];
|
|
215
|
+
/** Subgraph attributes buffered until the subgraph closes (the root applies them at once). */
|
|
216
|
+
readonly attributes: Map<string, DotAttribute>;
|
|
217
|
+
/** Whether the subgraph is a cluster (by name or by `cluster=true`). */
|
|
218
|
+
cluster: boolean;
|
|
219
|
+
/** The container node index once created, or INVALID_INDEX. */
|
|
220
|
+
container: number;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The importer plugin for DOT / Graphviz text (design section 12.4).
|
|
225
|
+
*/
|
|
226
|
+
export const dotImporter: GraphImporter<DotImportOptions> = Object.freeze({
|
|
227
|
+
format: DOT_FORMAT,
|
|
228
|
+
extensions: EXTENSIONS,
|
|
229
|
+
mimeTypes: MIME_TYPES,
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Confidence that the head of an input is DOT: the `[strict] graph | digraph` header after
|
|
233
|
+
* optional comments.
|
|
234
|
+
* @param head - the first bytes of the input
|
|
235
|
+
* @returns 0.95 for a header followed by `{`, 0.8 for a header alone, 0 otherwise
|
|
236
|
+
*/
|
|
237
|
+
sniff(head: Uint8Array): number {
|
|
238
|
+
const text = stripLeadingComments(new TextDecoder("utf-8").decode(head));
|
|
239
|
+
const match = DOT_HEADER.exec(text);
|
|
240
|
+
if (match === null) {
|
|
241
|
+
return 0;
|
|
242
|
+
}
|
|
243
|
+
return text.includes("{") ? 0.95 : 0.8;
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Read a DOT document into the sink.
|
|
248
|
+
* @param input - the text, bytes or stream
|
|
249
|
+
* @param sink - the sink to push into
|
|
250
|
+
* @param options - format-specific and common options
|
|
251
|
+
* @returns the import report; ImportError (E_IMPORT) on a syntax error or beyond the error limit
|
|
252
|
+
*/
|
|
253
|
+
async import(
|
|
254
|
+
input: ImportInput,
|
|
255
|
+
sink: GraphSink,
|
|
256
|
+
options?: DotImportOptions & CommonImportOptions,
|
|
257
|
+
): Promise<ImportReport> {
|
|
258
|
+
const resolved = resolveImportOptions(options, {
|
|
259
|
+
ids: "canonical",
|
|
260
|
+
defaultDirected: true,
|
|
261
|
+
weightFrom: "weight",
|
|
262
|
+
});
|
|
263
|
+
const mismatch = mismatchOption(options?.mismatchedEdgeOperator);
|
|
264
|
+
const report = new ImportReportBuilder(DOT_FORMAT, resolved.errorLimit);
|
|
265
|
+
reportUnusedOptions(options, report, USED_OPTIONS);
|
|
266
|
+
reportSinkOptions(sink, options, report);
|
|
267
|
+
const text = await readText(input, report, resolved);
|
|
268
|
+
const parser = new DotParser(text, sink, report, resolved, mismatch);
|
|
269
|
+
try {
|
|
270
|
+
parser.parse();
|
|
271
|
+
} catch (err) {
|
|
272
|
+
if (err instanceof DotSyntaxError) {
|
|
273
|
+
report.fail(SYNTAX_CODE, err.message, { line: err.line }, { line: err.line });
|
|
274
|
+
}
|
|
275
|
+
throw err;
|
|
276
|
+
}
|
|
277
|
+
// an abort raised during the last few statements (after the last periodic check) still rejects
|
|
278
|
+
throwIfAborted(resolved.signal);
|
|
279
|
+
return report.finish();
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Resolve the mismatchedEdgeOperator option.
|
|
285
|
+
* @param value - the caller's value
|
|
286
|
+
* @returns the value or the default; E_UNSUPPORTED for anything else
|
|
287
|
+
*/
|
|
288
|
+
function mismatchOption(value: unknown): "operator" | "header" | "error" {
|
|
289
|
+
if (value === undefined) {
|
|
290
|
+
return "operator";
|
|
291
|
+
}
|
|
292
|
+
if (value === "operator" || value === "header" || value === "error") {
|
|
293
|
+
return value;
|
|
294
|
+
}
|
|
295
|
+
throw new GraphFormatError(
|
|
296
|
+
"E_UNSUPPORTED",
|
|
297
|
+
`option mismatchedEdgeOperator: ${JSON.stringify(value)} is not one of "operator", "header", "error"`,
|
|
298
|
+
{ option: "mismatchedEdgeOperator", found: value, supported: ["operator", "header", "error"] },
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Remove leading whitespace and comments from a head sample so the header regex sees the keyword.
|
|
304
|
+
* @param text - the decoded head
|
|
305
|
+
* @returns the text from the first non-comment character
|
|
306
|
+
*/
|
|
307
|
+
function stripLeadingComments(text: string): string {
|
|
308
|
+
let rest = text;
|
|
309
|
+
for (;;) {
|
|
310
|
+
const trimmed = rest.replace(/^\s+/, "");
|
|
311
|
+
if (trimmed.startsWith("//") || trimmed.startsWith("#")) {
|
|
312
|
+
const nl = trimmed.search(/[\r\n]/);
|
|
313
|
+
if (nl < 0) {
|
|
314
|
+
return "";
|
|
315
|
+
}
|
|
316
|
+
rest = trimmed.slice(nl);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (trimmed.startsWith("/*")) {
|
|
320
|
+
const end = trimmed.indexOf("*/");
|
|
321
|
+
if (end < 0) {
|
|
322
|
+
return "";
|
|
323
|
+
}
|
|
324
|
+
rest = trimmed.slice(end + 2);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
return trimmed;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Whether a bare token is a given keyword (keywords are case-insensitive; a quoted id never is).
|
|
333
|
+
* @param token - the token
|
|
334
|
+
* @param keyword - the lower-case keyword
|
|
335
|
+
* @returns true for a match
|
|
336
|
+
*/
|
|
337
|
+
function isKeyword(token: DotToken, keyword: string): boolean {
|
|
338
|
+
return token.kind === "id" && !token.quoted && !token.html && token.text.toLowerCase() === keyword;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Whether a token is a given punctuation.
|
|
343
|
+
* @param token - the token
|
|
344
|
+
* @param text - the punctuation
|
|
345
|
+
* @returns true for a match
|
|
346
|
+
*/
|
|
347
|
+
function isPunct(token: DotToken, text: string): boolean {
|
|
348
|
+
return token.kind === "punct" && token.text === text;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Whether a token is an edge operator.
|
|
353
|
+
* @param token - the token
|
|
354
|
+
* @returns true for `->` or `--`
|
|
355
|
+
*/
|
|
356
|
+
function isEdgeOp(token: DotToken): boolean {
|
|
357
|
+
return token.kind === "punct" && (token.text === "->" || token.text === "--");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* A short description of a token for syntax error messages.
|
|
362
|
+
* @param token - the token
|
|
363
|
+
* @returns `end of input`, or the token text in quotes
|
|
364
|
+
*/
|
|
365
|
+
function describeToken(token: DotToken): string {
|
|
366
|
+
if (token.kind === "eof") {
|
|
367
|
+
return "end of input";
|
|
368
|
+
}
|
|
369
|
+
return JSON.stringify(token.text.length > 40 ? `${token.text.slice(0, 40)}...` : token.text);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Whether a subgraph name marks a cluster.
|
|
374
|
+
* @param name - the name, or null
|
|
375
|
+
* @returns true when it starts with `cluster`
|
|
376
|
+
*/
|
|
377
|
+
function isClusterName(name: string | null): boolean {
|
|
378
|
+
return name !== null && name.startsWith(CLUSTER_PREFIX);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* The parser and pusher for one import call.
|
|
383
|
+
*/
|
|
384
|
+
class DotParser {
|
|
385
|
+
private readonly lexer: DotTokenizer;
|
|
386
|
+
|
|
387
|
+
private readonly sink: GraphSink;
|
|
388
|
+
|
|
389
|
+
private readonly report: ImportReportBuilder;
|
|
390
|
+
|
|
391
|
+
private readonly options: ResolvedImportOptions;
|
|
392
|
+
|
|
393
|
+
private readonly mismatch: "operator" | "header" | "error";
|
|
394
|
+
|
|
395
|
+
private readonly ids: IdCoercer;
|
|
396
|
+
|
|
397
|
+
private readonly resolver: DirectionResolver;
|
|
398
|
+
|
|
399
|
+
private directed = true;
|
|
400
|
+
|
|
401
|
+
private strict = false;
|
|
402
|
+
|
|
403
|
+
private statements = 0;
|
|
404
|
+
|
|
405
|
+
/** The id of every node mentioned so far, by index (the sink has no idOf). */
|
|
406
|
+
private readonly idOf = new Map<number, NodeId>();
|
|
407
|
+
|
|
408
|
+
/** Container nodes this import created or adopted. */
|
|
409
|
+
private readonly containers = new Set<number>();
|
|
410
|
+
|
|
411
|
+
/** The parent assigned to a node by this import. */
|
|
412
|
+
private readonly parentOf = new Map<number, number>();
|
|
413
|
+
|
|
414
|
+
/** Nodes already warned about a cluster conflict. */
|
|
415
|
+
private readonly conflictWarned = new Set<number>();
|
|
416
|
+
|
|
417
|
+
/** Under `strict`: the edge index of every (source, target) pair pushed. */
|
|
418
|
+
private readonly strictEdges = new Map<string, number>();
|
|
419
|
+
|
|
420
|
+
/** The edge index of every (source, target, key) triple pushed. */
|
|
421
|
+
private readonly keyedEdges = new Map<string, number>();
|
|
422
|
+
|
|
423
|
+
/** Columns of the caller's sink adopted under a name the importer declares with another shape; values are inferred for them. */
|
|
424
|
+
|
|
425
|
+
private nodeLabelHandle: ColumnHandle = INVALID_INDEX as ColumnHandle;
|
|
426
|
+
|
|
427
|
+
private edgeLabelHandle: ColumnHandle = INVALID_INDEX as ColumnHandle;
|
|
428
|
+
|
|
429
|
+
private positionHandle: ColumnHandle = INVALID_INDEX as ColumnHandle;
|
|
430
|
+
|
|
431
|
+
private clusterHandle: ColumnHandle = INVALID_INDEX as ColumnHandle;
|
|
432
|
+
|
|
433
|
+
private parentHandle: ColumnHandle = INVALID_INDEX as ColumnHandle;
|
|
434
|
+
|
|
435
|
+
private keyHandle: ColumnHandle = INVALID_INDEX as ColumnHandle;
|
|
436
|
+
|
|
437
|
+
private sourcePortHandle: ColumnHandle = INVALID_INDEX as ColumnHandle;
|
|
438
|
+
|
|
439
|
+
private targetPortHandle: ColumnHandle = INVALID_INDEX as ColumnHandle;
|
|
440
|
+
|
|
441
|
+
/** The current subgraph nesting depth. */
|
|
442
|
+
private depth = 0;
|
|
443
|
+
|
|
444
|
+
/** The inferred attribute columns by name, per domain (the 5.1 text grammar per column). */
|
|
445
|
+
private readonly nodeWriters = new Map<string, TextCellWriter>();
|
|
446
|
+
|
|
447
|
+
private readonly edgeWriters = new Map<string, TextCellWriter>();
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Create a parser over one document.
|
|
451
|
+
* @param text - the DOT text
|
|
452
|
+
* @param sink - the sink
|
|
453
|
+
* @param report - the report
|
|
454
|
+
* @param options - the resolved common options
|
|
455
|
+
* @param mismatch - the resolved mismatchedEdgeOperator option
|
|
456
|
+
*/
|
|
457
|
+
constructor(
|
|
458
|
+
text: string,
|
|
459
|
+
sink: GraphSink,
|
|
460
|
+
report: ImportReportBuilder,
|
|
461
|
+
options: ResolvedImportOptions,
|
|
462
|
+
mismatch: "operator" | "header" | "error",
|
|
463
|
+
) {
|
|
464
|
+
this.lexer = new DotTokenizer(text, (numeral, line) => {
|
|
465
|
+
report.warning(
|
|
466
|
+
"validation-error",
|
|
467
|
+
DOT_ISSUE.NUMERAL_AMBIGUITY,
|
|
468
|
+
`badly delimited number ${JSON.stringify(numeral)} splits into two tokens (Graphviz warns the same)`,
|
|
469
|
+
{ line, element: numeral },
|
|
470
|
+
);
|
|
471
|
+
});
|
|
472
|
+
this.sink = sink;
|
|
473
|
+
this.report = report;
|
|
474
|
+
this.options = options;
|
|
475
|
+
this.mismatch = mismatch;
|
|
476
|
+
this.ids = new IdCoercer(options.ids);
|
|
477
|
+
this.resolver = new DirectionResolver(sink, report, options.onMixedDirection);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Parse the whole document: `[strict] (graph | digraph) [ID] { stmt_list }`.
|
|
482
|
+
*/
|
|
483
|
+
parse(): void {
|
|
484
|
+
const { lexer } = this;
|
|
485
|
+
let token = lexer.next();
|
|
486
|
+
if (token.kind === "eof") {
|
|
487
|
+
this.report.fail(EMPTY_INPUT_CODE, "the input holds no graph (empty or only comments)", {
|
|
488
|
+
line: token.line,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
if (isKeyword(token, "strict")) {
|
|
492
|
+
this.strict = true;
|
|
493
|
+
token = lexer.next();
|
|
494
|
+
}
|
|
495
|
+
if (isKeyword(token, "digraph")) {
|
|
496
|
+
this.directed = true;
|
|
497
|
+
} else if (isKeyword(token, "graph")) {
|
|
498
|
+
this.directed = false;
|
|
499
|
+
} else {
|
|
500
|
+
throw new DotSyntaxError(`expected "graph" or "digraph", found ${describeToken(token)}`, token.line);
|
|
501
|
+
}
|
|
502
|
+
const headerLine = token.line;
|
|
503
|
+
token = lexer.next();
|
|
504
|
+
let name: string | null = null;
|
|
505
|
+
if (token.kind === "id") {
|
|
506
|
+
name = token.text;
|
|
507
|
+
token = lexer.next();
|
|
508
|
+
}
|
|
509
|
+
if (!isPunct(token, "{")) {
|
|
510
|
+
throw new DotSyntaxError(`expected "{" after the graph header, found ${describeToken(token)}`, token.line);
|
|
511
|
+
}
|
|
512
|
+
this.resolver.setHeader(this.directed, { line: headerLine });
|
|
513
|
+
this.sink.setMeta({
|
|
514
|
+
name,
|
|
515
|
+
sourceFormat: DOT_FORMAT,
|
|
516
|
+
...(this.strict ? { extra: { dot: { strict: true } } } : {}),
|
|
517
|
+
});
|
|
518
|
+
const root = this.newScope(null, headerLine, true, null);
|
|
519
|
+
this.statementList(root);
|
|
520
|
+
const trailing = lexer.next();
|
|
521
|
+
if (trailing.kind !== "eof") {
|
|
522
|
+
this.report.warning(
|
|
523
|
+
"unsupported",
|
|
524
|
+
MULTIPLE_GRAPHS_CODE,
|
|
525
|
+
`content after the closing brace of the graph (${describeToken(trailing)}) was not read; one graph per input`,
|
|
526
|
+
{ line: trailing.line },
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Create a scope.
|
|
533
|
+
* @param parent - the enclosing scope, or null for the root
|
|
534
|
+
* @param line - the line the scope opens on
|
|
535
|
+
* @param root - whether this is the root graph
|
|
536
|
+
* @param name - the subgraph name, or null
|
|
537
|
+
* @returns the scope
|
|
538
|
+
*/
|
|
539
|
+
private newScope(parent: Scope | null, line: number, root: boolean, name: string | null): Scope {
|
|
540
|
+
return {
|
|
541
|
+
root,
|
|
542
|
+
name,
|
|
543
|
+
line,
|
|
544
|
+
nodeDefaults: new Map(parent?.nodeDefaults),
|
|
545
|
+
edgeDefaults: new Map(parent?.edgeDefaults),
|
|
546
|
+
members: [],
|
|
547
|
+
memberSet: new Set(),
|
|
548
|
+
containers: [],
|
|
549
|
+
attributes: new Map(),
|
|
550
|
+
cluster: false,
|
|
551
|
+
container: INVALID_INDEX,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Parse `stmt_list }` for a scope whose `{` was consumed.
|
|
557
|
+
* @param scope - the scope
|
|
558
|
+
*/
|
|
559
|
+
private statementList(scope: Scope): void {
|
|
560
|
+
const { lexer } = this;
|
|
561
|
+
for (;;) {
|
|
562
|
+
const token = lexer.peek();
|
|
563
|
+
if (isPunct(token, "}")) {
|
|
564
|
+
lexer.next();
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
if (token.kind === "eof") {
|
|
568
|
+
throw new DotSyntaxError(
|
|
569
|
+
scope.root
|
|
570
|
+
? 'unexpected end of input: missing "}" closing the graph'
|
|
571
|
+
: `unexpected end of input: missing "}" closing the subgraph opened on line ${scope.line}`,
|
|
572
|
+
token.line,
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
if (isPunct(token, ";")) {
|
|
576
|
+
lexer.next();
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
this.statement(scope);
|
|
580
|
+
if (++this.statements % STATEMENTS_PER_ABORT_CHECK === 0) {
|
|
581
|
+
throwIfAborted(this.options.signal);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Parse one statement of a scope.
|
|
588
|
+
* @param scope - the scope
|
|
589
|
+
*/
|
|
590
|
+
private statement(scope: Scope): void {
|
|
591
|
+
const { lexer } = this;
|
|
592
|
+
const token = lexer.peek();
|
|
593
|
+
if (token.kind === "punct") {
|
|
594
|
+
if (token.text === "{") {
|
|
595
|
+
const group = this.subgraph(scope);
|
|
596
|
+
this.maybeEdgeStatement(scope, group, token.line);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
throw new DotSyntaxError(`unexpected ${describeToken(token)} at the start of a statement`, token.line);
|
|
600
|
+
}
|
|
601
|
+
if (!token.quoted && !token.html) {
|
|
602
|
+
const keyword = token.text.toLowerCase();
|
|
603
|
+
if (keyword === "node" || keyword === "edge" || keyword === "graph") {
|
|
604
|
+
lexer.next();
|
|
605
|
+
this.attributeStatement(scope, keyword, token.line);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
if (keyword === "subgraph") {
|
|
609
|
+
const group = this.subgraph(scope);
|
|
610
|
+
this.maybeEdgeStatement(scope, group, token.line);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
if (keyword === "digraph" || keyword === "strict") {
|
|
614
|
+
throw new DotSyntaxError(`unexpected keyword ${describeToken(token)} inside a graph`, token.line);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
// ID '=' ID, a node statement, or an edge statement starting with a node
|
|
618
|
+
const id = this.identifier();
|
|
619
|
+
if (isPunct(lexer.peek(), "=")) {
|
|
620
|
+
lexer.next();
|
|
621
|
+
const value = this.identifier();
|
|
622
|
+
this.scopeAttribute(scope, { name: id.text, value: value.text, line: id.line });
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
const port = this.port();
|
|
626
|
+
if (isEdgeOp(lexer.peek())) {
|
|
627
|
+
const endpoint = this.mentionEndpoint(scope, id, port);
|
|
628
|
+
this.edgeStatement(scope, endpoint === null ? [] : [endpoint], id.line);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
this.nodeStatement(scope, id, port);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Read an ID, concatenating quoted strings joined by `+`.
|
|
636
|
+
* @returns the id token (the concatenation keeps the first token's line and quoted flag)
|
|
637
|
+
*/
|
|
638
|
+
private identifier(): DotToken {
|
|
639
|
+
const { lexer } = this;
|
|
640
|
+
const token = lexer.next();
|
|
641
|
+
if (token.kind !== "id") {
|
|
642
|
+
throw new DotSyntaxError(`expected an identifier, found ${describeToken(token)}`, token.line);
|
|
643
|
+
}
|
|
644
|
+
if (!token.quoted || !isPunct(lexer.peek(), "+")) {
|
|
645
|
+
return token;
|
|
646
|
+
}
|
|
647
|
+
let { text } = token;
|
|
648
|
+
while (isPunct(lexer.peek(), "+")) {
|
|
649
|
+
const plus = lexer.next();
|
|
650
|
+
const more = lexer.next();
|
|
651
|
+
if (more.kind !== "id" || !more.quoted) {
|
|
652
|
+
throw new DotSyntaxError(
|
|
653
|
+
`expected a quoted string after "+", found ${describeToken(more)}`,
|
|
654
|
+
more.kind === "eof" ? plus.line : more.line,
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
text += more.text;
|
|
658
|
+
}
|
|
659
|
+
return { kind: "id", text, quoted: true, html: false, line: token.line };
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Read an optional port after a node id: `: ID [ : compass ]` or `: compass`.
|
|
664
|
+
* @returns the port text (`f0`, `f0:n`, `n`), or null when there is none
|
|
665
|
+
*/
|
|
666
|
+
private port(): string | null {
|
|
667
|
+
const { lexer } = this;
|
|
668
|
+
if (!isPunct(lexer.peek(), ":")) {
|
|
669
|
+
return null;
|
|
670
|
+
}
|
|
671
|
+
lexer.next();
|
|
672
|
+
let { text } = this.identifier();
|
|
673
|
+
if (isPunct(lexer.peek(), ":")) {
|
|
674
|
+
lexer.next();
|
|
675
|
+
text += `:${this.identifier().text}`;
|
|
676
|
+
}
|
|
677
|
+
return text;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Parse `[subgraph [ID]] { stmt_list }` and return its member nodes for use as an edge endpoint.
|
|
682
|
+
* @param parent - the enclosing scope
|
|
683
|
+
* @returns the endpoints (every node mentioned in the subgraph, in first-mention order)
|
|
684
|
+
*/
|
|
685
|
+
private subgraph(parent: Scope): Endpoint[] {
|
|
686
|
+
const { lexer } = this;
|
|
687
|
+
let token = lexer.next();
|
|
688
|
+
let name: string | null = null;
|
|
689
|
+
if (isKeyword(token, "subgraph")) {
|
|
690
|
+
token = lexer.next();
|
|
691
|
+
if (token.kind === "id") {
|
|
692
|
+
name = token.text;
|
|
693
|
+
token = lexer.next();
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
if (!isPunct(token, "{")) {
|
|
697
|
+
throw new DotSyntaxError(`expected "{" to open a subgraph, found ${describeToken(token)}`, token.line);
|
|
698
|
+
}
|
|
699
|
+
if (++this.depth > MAX_NESTING) {
|
|
700
|
+
this.report.fail(
|
|
701
|
+
DOT_ISSUE.NESTING,
|
|
702
|
+
`subgraphs nested deeper than ${MAX_NESTING} levels (the parser recurses per level)`,
|
|
703
|
+
{ line: token.line },
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
const scope = this.newScope(parent, token.line, false, name);
|
|
707
|
+
if (isClusterName(name)) {
|
|
708
|
+
scope.cluster = true;
|
|
709
|
+
this.ensureContainer(scope);
|
|
710
|
+
}
|
|
711
|
+
this.statementList(scope);
|
|
712
|
+
this.depth--;
|
|
713
|
+
this.closeScope(scope, parent);
|
|
714
|
+
return scope.members.map((index) => ({ id: this.idOf.get(index) ?? index, index, port: null }));
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Finish a subgraph: apply its attributes to the container node when it is a cluster (or report
|
|
719
|
+
* them dropped), assign parents to its members, and propagate members and containers upward.
|
|
720
|
+
* @param scope - the closed scope
|
|
721
|
+
* @param parent - the enclosing scope
|
|
722
|
+
*/
|
|
723
|
+
private closeScope(scope: Scope, parent: Scope): void {
|
|
724
|
+
const { container } = scope;
|
|
725
|
+
if (container !== INVALID_INDEX) {
|
|
726
|
+
for (const attribute of scope.attributes.values()) {
|
|
727
|
+
if (attribute.name !== CLUSTER_ATTRIBUTE) {
|
|
728
|
+
this.setNodeAttribute(container, attribute.name, attribute.value, attribute.line);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
for (const nested of scope.containers) {
|
|
732
|
+
if (nested !== container && !this.parentOf.has(nested)) {
|
|
733
|
+
this.setParent(nested, container);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
for (const member of scope.members) {
|
|
737
|
+
if (member === container) {
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
const existing = this.parentOf.get(member);
|
|
741
|
+
if (existing === undefined) {
|
|
742
|
+
this.setParent(member, container);
|
|
743
|
+
} else if (existing !== container && !this.isAncestor(container, existing)) {
|
|
744
|
+
this.clusterConflict(member, existing, container, scope.line);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
parent.containers.push(container);
|
|
748
|
+
} else {
|
|
749
|
+
if (scope.attributes.size > 0) {
|
|
750
|
+
const names = [...scope.attributes.keys()].join(", ");
|
|
751
|
+
this.report.warning(
|
|
752
|
+
"unsupported",
|
|
753
|
+
DOT_ISSUE.SUBGRAPH_ATTRIBUTES_DROPPED,
|
|
754
|
+
`subgraph ${scope.name === null ? "(anonymous)" : JSON.stringify(scope.name)} is not a cluster; its attributes (${names}) cannot be represented and were dropped`,
|
|
755
|
+
{ line: scope.line, element: scope.name },
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
for (const nested of scope.containers) {
|
|
759
|
+
parent.containers.push(nested);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
if (!parent.root) {
|
|
763
|
+
for (const member of scope.members) {
|
|
764
|
+
if (!parent.memberSet.has(member)) {
|
|
765
|
+
parent.memberSet.add(member);
|
|
766
|
+
parent.members.push(member);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Whether `candidate` is an ancestor of `node` through the parents assigned so far.
|
|
774
|
+
* @param candidate - the possible ancestor
|
|
775
|
+
* @param node - the node whose chain is walked
|
|
776
|
+
* @returns true when candidate is reached
|
|
777
|
+
*/
|
|
778
|
+
private isAncestor(candidate: number, node: number): boolean {
|
|
779
|
+
let current: number | undefined = node;
|
|
780
|
+
for (let steps = 0; current !== undefined && steps < MAX_ANCESTOR_WALK; steps++) {
|
|
781
|
+
if (current === candidate) {
|
|
782
|
+
return true;
|
|
783
|
+
}
|
|
784
|
+
current = this.parentOf.get(current);
|
|
785
|
+
}
|
|
786
|
+
return false;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Record a node mentioned in two unrelated clusters (once per node).
|
|
791
|
+
* @param member - the node
|
|
792
|
+
* @param existing - its parent
|
|
793
|
+
* @param container - the cluster it was also mentioned in
|
|
794
|
+
* @param line - the line of the losing cluster
|
|
795
|
+
*/
|
|
796
|
+
private clusterConflict(member: number, existing: number, container: number, line: number): void {
|
|
797
|
+
if (this.conflictWarned.has(member)) {
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
this.conflictWarned.add(member);
|
|
801
|
+
const id = this.idOf.get(member) ?? member;
|
|
802
|
+
this.report.warning(
|
|
803
|
+
"coercion",
|
|
804
|
+
DOT_ISSUE.CLUSTER_CONFLICT,
|
|
805
|
+
`node ${JSON.stringify(id)} is in cluster ${JSON.stringify(this.idOf.get(existing) ?? existing)} and in cluster ${JSON.stringify(this.idOf.get(container) ?? container)}; the first is kept`,
|
|
806
|
+
{ line, element: String(id) },
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Assign a parent.
|
|
812
|
+
* @param node - the node index
|
|
813
|
+
* @param container - the container index
|
|
814
|
+
*/
|
|
815
|
+
private setParent(node: number, container: number): void {
|
|
816
|
+
if (node === container) {
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
try {
|
|
820
|
+
this.sink.setNodeValue(this.parentColumn(), node, container);
|
|
821
|
+
this.parentOf.set(node, container);
|
|
822
|
+
} catch (err) {
|
|
823
|
+
this.report.recordError(err, { element: String(this.idOf.get(node) ?? node) });
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Create (or adopt) the container node of a cluster scope.
|
|
829
|
+
* @param scope - the cluster scope
|
|
830
|
+
*/
|
|
831
|
+
private ensureContainer(scope: Scope): void {
|
|
832
|
+
if (scope.container !== INVALID_INDEX || scope.name === null) {
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
const id = this.coerceId(scope.name, scope.line);
|
|
836
|
+
if (id === null) {
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
let index = this.sink.indexOf(id);
|
|
840
|
+
try {
|
|
841
|
+
if (index === INVALID_INDEX) {
|
|
842
|
+
index = this.sink.addNode(id);
|
|
843
|
+
this.report.counts.nodes++;
|
|
844
|
+
} else if (!this.containers.has(index)) {
|
|
845
|
+
this.report.warning(
|
|
846
|
+
"coercion",
|
|
847
|
+
DOT_ISSUE.CLUSTER_NODE_MERGED,
|
|
848
|
+
`cluster ${JSON.stringify(scope.name)} and the node of the same name were merged into one container node`,
|
|
849
|
+
{ line: scope.line, element: scope.name },
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
this.idOf.set(index, id);
|
|
853
|
+
if (!this.containers.has(index)) {
|
|
854
|
+
this.containers.add(index);
|
|
855
|
+
this.sink.setNodeValue(this.clusterColumn(), index, true);
|
|
856
|
+
}
|
|
857
|
+
scope.container = index;
|
|
858
|
+
} catch (err) {
|
|
859
|
+
this.report.recordError(err, { line: scope.line, element: scope.name });
|
|
860
|
+
if (index === INVALID_INDEX) {
|
|
861
|
+
this.report.counts.skippedNodes++;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Apply an `ID = ID` or `graph [..]` attribute: the graph table at the root, a buffered
|
|
868
|
+
* subgraph attribute otherwise (`cluster=true` turns the subgraph into a cluster).
|
|
869
|
+
* @param scope - the scope
|
|
870
|
+
* @param attribute - the attribute
|
|
871
|
+
*/
|
|
872
|
+
private scopeAttribute(scope: Scope, attribute: DotAttribute): void {
|
|
873
|
+
if (scope.root) {
|
|
874
|
+
this.setGraphAttribute(attribute);
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
scope.attributes.set(attribute.name, attribute);
|
|
878
|
+
if (attribute.name === CLUSTER_ATTRIBUTE && TRUE_TEXTS.has(attribute.value.trim().toLowerCase())) {
|
|
879
|
+
scope.cluster = true;
|
|
880
|
+
this.ensureContainer(scope);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Parse `node|edge|graph attr_list` after the keyword.
|
|
886
|
+
* @param scope - the scope
|
|
887
|
+
* @param keyword - which defaults are set
|
|
888
|
+
* @param line - the keyword's line
|
|
889
|
+
*/
|
|
890
|
+
private attributeStatement(scope: Scope, keyword: string, line: number): void {
|
|
891
|
+
if (!isPunct(this.lexer.peek(), "[")) {
|
|
892
|
+
const found = this.lexer.peek();
|
|
893
|
+
throw new DotSyntaxError(`expected "[" after "${keyword}", found ${describeToken(found)}`, found.line);
|
|
894
|
+
}
|
|
895
|
+
const attributes = this.attributeList();
|
|
896
|
+
switch (keyword) {
|
|
897
|
+
case "node":
|
|
898
|
+
for (const a of attributes) {
|
|
899
|
+
scope.nodeDefaults.set(a.name, a.value);
|
|
900
|
+
}
|
|
901
|
+
break;
|
|
902
|
+
case "edge":
|
|
903
|
+
for (const a of attributes) {
|
|
904
|
+
scope.edgeDefaults.set(a.name, a.value);
|
|
905
|
+
}
|
|
906
|
+
break;
|
|
907
|
+
case "graph":
|
|
908
|
+
for (const a of attributes) {
|
|
909
|
+
this.scopeAttribute(scope, a);
|
|
910
|
+
}
|
|
911
|
+
break;
|
|
912
|
+
default:
|
|
913
|
+
throw new DotSyntaxError(`unknown attribute statement "${keyword}"`, line);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Parse one or more `[ a_list ]` groups.
|
|
919
|
+
* @returns the attributes in order (a repeated name keeps its last value at application time)
|
|
920
|
+
*/
|
|
921
|
+
private attributeList(): DotAttribute[] {
|
|
922
|
+
const { lexer } = this;
|
|
923
|
+
const out: DotAttribute[] = [];
|
|
924
|
+
while (isPunct(lexer.peek(), "[")) {
|
|
925
|
+
lexer.next();
|
|
926
|
+
for (;;) {
|
|
927
|
+
const token = lexer.peek();
|
|
928
|
+
if (isPunct(token, "]")) {
|
|
929
|
+
lexer.next();
|
|
930
|
+
break;
|
|
931
|
+
}
|
|
932
|
+
if (isPunct(token, ";") || isPunct(token, ",")) {
|
|
933
|
+
lexer.next();
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
const name = this.identifier();
|
|
937
|
+
const eq = lexer.next();
|
|
938
|
+
if (!isPunct(eq, "=")) {
|
|
939
|
+
throw new DotSyntaxError(
|
|
940
|
+
`expected "=" after attribute name ${JSON.stringify(name.text)}, found ${describeToken(eq)}`,
|
|
941
|
+
eq.line,
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
const value = this.identifier();
|
|
945
|
+
out.push({ name: name.text, value: value.text, line: name.line });
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
return out;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* Parse a node statement after its id and port: an optional attribute list, then apply.
|
|
953
|
+
* @param scope - the scope
|
|
954
|
+
* @param id - the id token
|
|
955
|
+
* @param port - the port, if any (dropped with a warning)
|
|
956
|
+
*/
|
|
957
|
+
private nodeStatement(scope: Scope, id: DotToken, port: string | null): void {
|
|
958
|
+
const attributes = this.attributeList();
|
|
959
|
+
const endpoint = this.mentionEndpoint(scope, id, null);
|
|
960
|
+
if (endpoint === null) {
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
if (port !== null) {
|
|
964
|
+
this.report.warning(
|
|
965
|
+
"unsupported",
|
|
966
|
+
DOT_ISSUE.NODE_PORT_DROPPED,
|
|
967
|
+
`port ${JSON.stringify(port)} on the node statement of ${JSON.stringify(id.text)} has no meaning and was dropped`,
|
|
968
|
+
{ line: id.line, element: id.text },
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
for (const attribute of attributes) {
|
|
972
|
+
this.setNodeAttribute(endpoint.index, attribute.name, attribute.value, attribute.line);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* After a subgraph statement: continue as an edge statement when an edge operator follows.
|
|
978
|
+
* @param scope - the scope
|
|
979
|
+
* @param group - the subgraph's members
|
|
980
|
+
* @param line - the statement's line
|
|
981
|
+
*/
|
|
982
|
+
private maybeEdgeStatement(scope: Scope, group: Endpoint[], line: number): void {
|
|
983
|
+
if (isEdgeOp(this.lexer.peek())) {
|
|
984
|
+
this.edgeStatement(scope, group, line);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* Parse `edgeRHS [attr_list]` after the first endpoint group and push the edges.
|
|
990
|
+
* @param scope - the scope
|
|
991
|
+
* @param first - the first group (empty when its node could not be created)
|
|
992
|
+
* @param line - the statement's line
|
|
993
|
+
*/
|
|
994
|
+
private edgeStatement(scope: Scope, first: Endpoint[], line: number): void {
|
|
995
|
+
const { lexer } = this;
|
|
996
|
+
const groups: Endpoint[][] = [first];
|
|
997
|
+
const kinds: EdgeKind[] = [];
|
|
998
|
+
while (isEdgeOp(lexer.peek())) {
|
|
999
|
+
const op = lexer.next();
|
|
1000
|
+
kinds.push(this.edgeKind(op));
|
|
1001
|
+
const next = lexer.peek();
|
|
1002
|
+
if (isPunct(next, "{") || isKeyword(next, "subgraph")) {
|
|
1003
|
+
groups.push(this.subgraph(scope));
|
|
1004
|
+
} else {
|
|
1005
|
+
const id = this.identifier();
|
|
1006
|
+
const port = this.port();
|
|
1007
|
+
const endpoint = this.mentionEndpoint(scope, id, port);
|
|
1008
|
+
groups.push(endpoint === null ? [] : [endpoint]);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
const attributes = this.attributeList();
|
|
1012
|
+
for (let k = 0; k < kinds.length; k++) {
|
|
1013
|
+
for (const source of groups[k]) {
|
|
1014
|
+
for (const target of groups[k + 1]) {
|
|
1015
|
+
this.pushEdge(scope, source, target, kinds[k], attributes, line);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* The direction of an edge from its operator, checked against the graph keyword.
|
|
1023
|
+
* @param op - the operator token
|
|
1024
|
+
* @returns the edge kind
|
|
1025
|
+
*/
|
|
1026
|
+
private edgeKind(op: DotToken): EdgeKind {
|
|
1027
|
+
const operatorDirected = op.text === "->";
|
|
1028
|
+
if (operatorDirected === this.directed) {
|
|
1029
|
+
return operatorDirected ? "directed" : "undirected";
|
|
1030
|
+
}
|
|
1031
|
+
const message = `edge operator "${op.text}" in a ${this.directed ? "digraph" : "graph"}`;
|
|
1032
|
+
switch (this.mismatch) {
|
|
1033
|
+
case "error":
|
|
1034
|
+
throw new DotSyntaxError(`${message} (mismatchedEdgeOperator: "error")`, op.line);
|
|
1035
|
+
case "header":
|
|
1036
|
+
this.report.warnOnce(
|
|
1037
|
+
"coercion",
|
|
1038
|
+
DOT_ISSUE.EDGE_OPERATOR,
|
|
1039
|
+
`${message}; read with the graph's direction (mismatchedEdgeOperator: "header")`,
|
|
1040
|
+
{ line: op.line },
|
|
1041
|
+
);
|
|
1042
|
+
return this.directed ? "directed" : "undirected";
|
|
1043
|
+
case "operator":
|
|
1044
|
+
this.report.warnOnce(
|
|
1045
|
+
"coercion",
|
|
1046
|
+
DOT_ISSUE.EDGE_OPERATOR,
|
|
1047
|
+
`${message}; read with the operator's direction and resolved per onMixedDirection`,
|
|
1048
|
+
{ line: op.line },
|
|
1049
|
+
);
|
|
1050
|
+
return operatorDirected ? "directed" : "undirected";
|
|
1051
|
+
default: {
|
|
1052
|
+
const name: string = this.mismatch;
|
|
1053
|
+
throw new GraphFormatError("E_UNSUPPORTED", `unknown mismatchedEdgeOperator ${name}`, { found: name });
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* Coerce an id text, recording a merge under ids: "number".
|
|
1060
|
+
* @param text - the id text
|
|
1061
|
+
* @param line - the line, for issues
|
|
1062
|
+
* @returns the id, or null when the text was rejected (recorded, node skipped)
|
|
1063
|
+
*/
|
|
1064
|
+
private coerceId(text: string, line: number): NodeId | null {
|
|
1065
|
+
try {
|
|
1066
|
+
const id = this.ids.text(text);
|
|
1067
|
+
const merge = this.ids.lastMerge;
|
|
1068
|
+
if (merge !== null) {
|
|
1069
|
+
this.report.warning(
|
|
1070
|
+
"coercion",
|
|
1071
|
+
DOT_ISSUE.ID_MERGED,
|
|
1072
|
+
`id text ${JSON.stringify(merge.text)} merged with ${JSON.stringify(merge.previousText)} as ${merge.id} under ids: "number"`,
|
|
1073
|
+
{ line, element: text },
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
return id;
|
|
1077
|
+
} catch (err) {
|
|
1078
|
+
this.report.recordError(err, { line, element: text });
|
|
1079
|
+
this.report.counts.skippedNodes++;
|
|
1080
|
+
return null;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* Mention a node: create it on first mention (applying the scope's node defaults), record it as
|
|
1086
|
+
* a member of the scope, and return it as an endpoint.
|
|
1087
|
+
* @param scope - the scope
|
|
1088
|
+
* @param id - the id token
|
|
1089
|
+
* @param port - the endpoint's port, or null
|
|
1090
|
+
* @returns the endpoint, or null when the node could not be created (recorded)
|
|
1091
|
+
*/
|
|
1092
|
+
private mentionEndpoint(scope: Scope, id: DotToken, port: string | null): Endpoint | null {
|
|
1093
|
+
const nodeId = this.coerceId(id.text, id.line);
|
|
1094
|
+
if (nodeId === null) {
|
|
1095
|
+
return null;
|
|
1096
|
+
}
|
|
1097
|
+
const { sink } = this;
|
|
1098
|
+
let index = sink.indexOf(nodeId);
|
|
1099
|
+
if (index === INVALID_INDEX) {
|
|
1100
|
+
try {
|
|
1101
|
+
index = sink.addNode(nodeId);
|
|
1102
|
+
} catch (err) {
|
|
1103
|
+
this.report.recordError(err, { line: id.line, element: id.text });
|
|
1104
|
+
this.report.counts.skippedNodes++;
|
|
1105
|
+
return null;
|
|
1106
|
+
}
|
|
1107
|
+
this.report.counts.nodes++;
|
|
1108
|
+
this.idOf.set(index, nodeId);
|
|
1109
|
+
for (const [name, value] of scope.nodeDefaults) {
|
|
1110
|
+
this.setNodeAttribute(index, name, value, id.line);
|
|
1111
|
+
}
|
|
1112
|
+
} else if (!this.idOf.has(index)) {
|
|
1113
|
+
this.idOf.set(index, nodeId);
|
|
1114
|
+
}
|
|
1115
|
+
if (!scope.root && !scope.memberSet.has(index)) {
|
|
1116
|
+
scope.memberSet.add(index);
|
|
1117
|
+
scope.members.push(index);
|
|
1118
|
+
}
|
|
1119
|
+
return { id: nodeId, index, port };
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* Push one edge with its effective attributes (scope defaults overridden by the statement's).
|
|
1124
|
+
* @param scope - the scope
|
|
1125
|
+
* @param source - the source endpoint
|
|
1126
|
+
* @param target - the target endpoint
|
|
1127
|
+
* @param kind - the edge's direction
|
|
1128
|
+
* @param attributes - the statement's attributes
|
|
1129
|
+
* @param line - the statement's line
|
|
1130
|
+
*/
|
|
1131
|
+
private pushEdge(
|
|
1132
|
+
scope: Scope,
|
|
1133
|
+
source: Endpoint,
|
|
1134
|
+
target: Endpoint,
|
|
1135
|
+
kind: EdgeKind,
|
|
1136
|
+
attributes: readonly DotAttribute[],
|
|
1137
|
+
line: number,
|
|
1138
|
+
): void {
|
|
1139
|
+
const effective = new Map(scope.edgeDefaults);
|
|
1140
|
+
for (const a of attributes) {
|
|
1141
|
+
effective.set(a.name, a.value);
|
|
1142
|
+
}
|
|
1143
|
+
const element = `${String(source.id)} ${kind === "directed" ? "->" : "--"} ${String(target.id)}`;
|
|
1144
|
+
const where: EdgeWhere = { line, element };
|
|
1145
|
+
const { weightFrom } = this.options;
|
|
1146
|
+
let weight: number | undefined;
|
|
1147
|
+
const weightText = weightFrom === null ? undefined : effective.get(weightFrom);
|
|
1148
|
+
if (weightText !== undefined) {
|
|
1149
|
+
try {
|
|
1150
|
+
weight = parseWeightText(weightText);
|
|
1151
|
+
} catch (err) {
|
|
1152
|
+
this.report.recordError(err, where);
|
|
1153
|
+
this.report.counts.skippedEdges++;
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
const key = effective.get(KEY_ATTRIBUTE);
|
|
1158
|
+
const dedupeKey = this.dedupeKey(source.index, target.index, kind, key);
|
|
1159
|
+
if (dedupeKey !== null) {
|
|
1160
|
+
const existing = (this.strict ? this.strictEdges : this.keyedEdges).get(dedupeKey);
|
|
1161
|
+
if (existing !== undefined) {
|
|
1162
|
+
this.mergeEdge(existing, weight, effective, where, key);
|
|
1163
|
+
this.setPorts(existing, source, target, where);
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
const { sink } = this;
|
|
1168
|
+
const before = sink.edgeCount;
|
|
1169
|
+
let e: number;
|
|
1170
|
+
try {
|
|
1171
|
+
e = this.resolver.addEdge(source.id, target.id, kind, weight, where);
|
|
1172
|
+
} catch (err) {
|
|
1173
|
+
this.report.recordError(err, where);
|
|
1174
|
+
this.report.counts.skippedEdges++;
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
this.report.counts.edges += sink.edgeCount - before;
|
|
1178
|
+
if (dedupeKey !== null) {
|
|
1179
|
+
(this.strict ? this.strictEdges : this.keyedEdges).set(dedupeKey, e);
|
|
1180
|
+
}
|
|
1181
|
+
for (const [name, value] of effective) {
|
|
1182
|
+
if (name !== weightFrom) {
|
|
1183
|
+
this.setEdgeAttribute(e, name, value, line, element);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
this.setPorts(e, source, target, where);
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* Write the endpoints' ports of an edge, when they have any.
|
|
1191
|
+
* @param e - the edge index
|
|
1192
|
+
* @param source - the source endpoint
|
|
1193
|
+
* @param target - the target endpoint
|
|
1194
|
+
* @param where - the line and element
|
|
1195
|
+
*/
|
|
1196
|
+
private setPorts(e: number, source: Endpoint, target: Endpoint, where: EdgeWhere): void {
|
|
1197
|
+
if (source.port !== null) {
|
|
1198
|
+
this.setPort(e, "source", source.port, where);
|
|
1199
|
+
}
|
|
1200
|
+
if (target.port !== null) {
|
|
1201
|
+
this.setPort(e, "target", target.port, where);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* The key under which an edge is merged with an earlier one: every (source, target) pair under
|
|
1207
|
+
* `strict` (unordered for an undirected edge), else the (source, target, key) triple of an edge
|
|
1208
|
+
* carrying a `key` attribute (cgraph's edge identity).
|
|
1209
|
+
* @param source - the source index
|
|
1210
|
+
* @param target - the target index
|
|
1211
|
+
* @param kind - the edge's direction
|
|
1212
|
+
* @param key - the `key` attribute, or undefined
|
|
1213
|
+
* @returns the dedupe key, or null when the edge is never merged
|
|
1214
|
+
*/
|
|
1215
|
+
private dedupeKey(source: number, target: number, kind: EdgeKind, key: string | undefined): string | null {
|
|
1216
|
+
const ordered = kind === "undirected" && target < source ? `${target}>${source}` : `${source}>${target}`;
|
|
1217
|
+
if (this.strict) {
|
|
1218
|
+
return ordered;
|
|
1219
|
+
}
|
|
1220
|
+
return key === undefined ? null : `${ordered}#${key}`;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* Merge a repeated edge into the earlier one: its attributes overwrite, an explicit weight too.
|
|
1225
|
+
* @param e - the existing edge index
|
|
1226
|
+
* @param weight - the repeated edge's weight, or undefined
|
|
1227
|
+
* @param effective - the repeated edge's effective attributes
|
|
1228
|
+
* @param where - the line and element
|
|
1229
|
+
* @param key - the `key` attribute when the merge is by key
|
|
1230
|
+
*/
|
|
1231
|
+
private mergeEdge(
|
|
1232
|
+
e: number,
|
|
1233
|
+
weight: number | undefined,
|
|
1234
|
+
effective: ReadonlyMap<string, string>,
|
|
1235
|
+
where: EdgeWhere,
|
|
1236
|
+
key: string | undefined,
|
|
1237
|
+
): void {
|
|
1238
|
+
const byKey = !this.strict && key !== undefined;
|
|
1239
|
+
this.report.warning(
|
|
1240
|
+
"merged",
|
|
1241
|
+
byKey ? DOT_ISSUE.KEY_MERGED : DOT_ISSUE.STRICT_MERGED,
|
|
1242
|
+
byKey
|
|
1243
|
+
? `edge ${where.element} with key ${JSON.stringify(key)} repeats an earlier edge; attributes merged`
|
|
1244
|
+
: `parallel edge ${where.element} merged into the earlier one (strict graph)`,
|
|
1245
|
+
where,
|
|
1246
|
+
);
|
|
1247
|
+
try {
|
|
1248
|
+
if (weight !== undefined) {
|
|
1249
|
+
this.sink.setEdgeWeight(e, weight);
|
|
1250
|
+
}
|
|
1251
|
+
} catch (err) {
|
|
1252
|
+
this.report.recordError(err, where);
|
|
1253
|
+
}
|
|
1254
|
+
for (const [name, value] of effective) {
|
|
1255
|
+
if (name !== this.options.weightFrom) {
|
|
1256
|
+
this.setEdgeAttribute(e, name, value, where.line, where.element);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* Write one node attribute: `label` to the label column, `pos` to the position column,
|
|
1263
|
+
* anything else as an inferred cell.
|
|
1264
|
+
* @param index - the node index
|
|
1265
|
+
* @param name - the attribute name
|
|
1266
|
+
* @param value - the value text
|
|
1267
|
+
* @param line - the line, for issues
|
|
1268
|
+
*/
|
|
1269
|
+
private setNodeAttribute(index: number, name: string, value: string, line: number): void {
|
|
1270
|
+
const element = String(this.idOf.get(index) ?? index);
|
|
1271
|
+
try {
|
|
1272
|
+
if (name === LABEL_ATTRIBUTE) {
|
|
1273
|
+
this.sink.setNodeValue(this.nodeLabel(), index, value);
|
|
1274
|
+
} else if (name === POS_ATTRIBUTE) {
|
|
1275
|
+
this.setPosition(index, value, line, element);
|
|
1276
|
+
} else {
|
|
1277
|
+
this.textWriter("node", name).write(index, value);
|
|
1278
|
+
}
|
|
1279
|
+
} catch (err) {
|
|
1280
|
+
this.report.recordError(err, { line, element });
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/**
|
|
1285
|
+
* Write one edge attribute: `label` to the label column, `key` to the edge id column, anything
|
|
1286
|
+
* else as an inferred cell.
|
|
1287
|
+
* @param e - the edge index
|
|
1288
|
+
* @param name - the attribute name
|
|
1289
|
+
* @param value - the value text
|
|
1290
|
+
* @param line - the line, for issues
|
|
1291
|
+
* @param element - the edge description, for issues
|
|
1292
|
+
*/
|
|
1293
|
+
private setEdgeAttribute(e: number, name: string, value: string, line: number, element: string): void {
|
|
1294
|
+
try {
|
|
1295
|
+
if (name === LABEL_ATTRIBUTE) {
|
|
1296
|
+
this.setEdgeText(this.edgeLabel(), e, value);
|
|
1297
|
+
} else if (name === KEY_ATTRIBUTE) {
|
|
1298
|
+
this.setEdgeText(this.keyColumn(), e, value);
|
|
1299
|
+
} else {
|
|
1300
|
+
this.textWriter("edge", name).write(e, value);
|
|
1301
|
+
}
|
|
1302
|
+
} catch (err) {
|
|
1303
|
+
this.report.recordError(err, { line, element });
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/**
|
|
1308
|
+
* Write a graph attribute (the root's `ID = ID` and `graph [..]`).
|
|
1309
|
+
* @param attribute - the attribute
|
|
1310
|
+
*/
|
|
1311
|
+
private setGraphAttribute(attribute: DotAttribute): void {
|
|
1312
|
+
try {
|
|
1313
|
+
if (attribute.name === LABEL_ATTRIBUTE) {
|
|
1314
|
+
this.sink.setGraphValue(attribute.name, attribute.value, { dtype: "string", origin: DOT_ORIGIN });
|
|
1315
|
+
} else {
|
|
1316
|
+
this.sink.setGraphValue(attribute.name, parseTextCell(attribute.value), { origin: DOT_ORIGIN });
|
|
1317
|
+
}
|
|
1318
|
+
} catch (err) {
|
|
1319
|
+
this.report.recordError(err, { line: attribute.line, element: attribute.name });
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/**
|
|
1324
|
+
* Write a node's `pos`: `x,y[,z][!]` into the position column, the `!` as `pin` = true.
|
|
1325
|
+
* @param index - the node index
|
|
1326
|
+
* @param text - the pos text
|
|
1327
|
+
* @param line - the line
|
|
1328
|
+
* @param element - the node id text
|
|
1329
|
+
*/
|
|
1330
|
+
private setPosition(index: number, text: string, line: number, element: string): void {
|
|
1331
|
+
const match = POINT_TEXT.exec(text);
|
|
1332
|
+
if (match === null) {
|
|
1333
|
+
this.report.warning(
|
|
1334
|
+
"validation-error",
|
|
1335
|
+
DOT_ISSUE.BAD_POS,
|
|
1336
|
+
`pos ${JSON.stringify(text)} is not a point "x,y[,z][!]"; dropped`,
|
|
1337
|
+
{ line, element },
|
|
1338
|
+
);
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
const x = Number(match[1]);
|
|
1342
|
+
const y = Number(match[2]);
|
|
1343
|
+
const z = match[3] === undefined ? 0 : Number(match[3]);
|
|
1344
|
+
const dims = match[3] === undefined ? 2 : 3;
|
|
1345
|
+
this.sink.setNodeValue(this.positionColumn(dims), index, [x, y, z]);
|
|
1346
|
+
if (match[4] === "!") {
|
|
1347
|
+
this.sink.setNodeValue(PIN_ATTRIBUTE, index, true);
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/**
|
|
1352
|
+
* Write an endpoint's port into the source / target port column.
|
|
1353
|
+
* @param e - the edge index
|
|
1354
|
+
* @param side - which endpoint
|
|
1355
|
+
* @param port - the port text
|
|
1356
|
+
* @param where - the line and element
|
|
1357
|
+
*/
|
|
1358
|
+
private setPort(e: number, side: "source" | "target", port: string, where: EdgeWhere): void {
|
|
1359
|
+
try {
|
|
1360
|
+
this.setEdgeText(side === "source" ? this.sourcePortColumn() : this.targetPortColumn(), e, port);
|
|
1361
|
+
} catch (err) {
|
|
1362
|
+
this.report.recordError(err, where);
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/**
|
|
1367
|
+
* Write a text cell into one of the importer's edge text columns.
|
|
1368
|
+
* @param handle - the column
|
|
1369
|
+
* @param e - the edge index
|
|
1370
|
+
* @param text - the text
|
|
1371
|
+
*/
|
|
1372
|
+
private setEdgeText(handle: ColumnHandle, e: number, text: string): void {
|
|
1373
|
+
this.sink.setEdgeValue(handle, e, text);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// ============================================================ lazily declared columns
|
|
1377
|
+
|
|
1378
|
+
/**
|
|
1379
|
+
* The node label column (string, role label).
|
|
1380
|
+
* @returns the handle
|
|
1381
|
+
*/
|
|
1382
|
+
private nodeLabel(): ColumnHandle {
|
|
1383
|
+
if (this.nodeLabelHandle === INVALID_INDEX) {
|
|
1384
|
+
this.nodeLabelHandle = this.declare("node", {
|
|
1385
|
+
name: LABEL_ATTRIBUTE,
|
|
1386
|
+
dtype: "string",
|
|
1387
|
+
nullable: true,
|
|
1388
|
+
role: "label",
|
|
1389
|
+
origin: DOT_ORIGIN,
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
return this.nodeLabelHandle;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/**
|
|
1396
|
+
* The edge label column (string, role label).
|
|
1397
|
+
* @returns the handle
|
|
1398
|
+
*/
|
|
1399
|
+
private edgeLabel(): ColumnHandle {
|
|
1400
|
+
if (this.edgeLabelHandle === INVALID_INDEX) {
|
|
1401
|
+
this.edgeLabelHandle = this.declare("edge", {
|
|
1402
|
+
name: LABEL_ATTRIBUTE,
|
|
1403
|
+
dtype: "string",
|
|
1404
|
+
nullable: true,
|
|
1405
|
+
role: "label",
|
|
1406
|
+
origin: DOT_ORIGIN,
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
return this.edgeLabelHandle;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* The position column (f32 x3, role position, design section 5.2), declared on the first `pos`.
|
|
1414
|
+
* @param dims - the dimensions of the first value, recorded in extra.sourceDims
|
|
1415
|
+
* @returns the handle
|
|
1416
|
+
*/
|
|
1417
|
+
private positionColumn(dims: 2 | 3): ColumnHandle {
|
|
1418
|
+
if (this.positionHandle === INVALID_INDEX) {
|
|
1419
|
+
this.positionHandle = this.declare("node", {
|
|
1420
|
+
name: POS_ATTRIBUTE,
|
|
1421
|
+
dtype: "f32",
|
|
1422
|
+
components: 3,
|
|
1423
|
+
nullable: true,
|
|
1424
|
+
mutable: true,
|
|
1425
|
+
role: "position",
|
|
1426
|
+
origin: { ...DOT_ORIGIN, type: "point" },
|
|
1427
|
+
extra: { sourceDims: dims, units: "file" },
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
return this.positionHandle;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
/**
|
|
1434
|
+
* The cluster marker column (bool).
|
|
1435
|
+
* @returns the handle
|
|
1436
|
+
*/
|
|
1437
|
+
private clusterColumn(): ColumnHandle {
|
|
1438
|
+
if (this.clusterHandle === INVALID_INDEX) {
|
|
1439
|
+
this.clusterHandle = this.declare("node", {
|
|
1440
|
+
name: CLUSTER_COLUMN,
|
|
1441
|
+
dtype: "bool",
|
|
1442
|
+
nullable: true,
|
|
1443
|
+
origin: DOT_ORIGIN,
|
|
1444
|
+
});
|
|
1445
|
+
}
|
|
1446
|
+
return this.clusterHandle;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/**
|
|
1450
|
+
* The parent column (u32, role parent, refersTo node).
|
|
1451
|
+
* @returns the handle
|
|
1452
|
+
*/
|
|
1453
|
+
private parentColumn(): ColumnHandle {
|
|
1454
|
+
if (this.parentHandle === INVALID_INDEX) {
|
|
1455
|
+
this.parentHandle = this.declare("node", {
|
|
1456
|
+
name: PARENT_COLUMN,
|
|
1457
|
+
dtype: "u32",
|
|
1458
|
+
nullable: true,
|
|
1459
|
+
role: "parent",
|
|
1460
|
+
refersTo: "node",
|
|
1461
|
+
origin: DOT_ORIGIN,
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
return this.parentHandle;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/**
|
|
1468
|
+
* The edge key column (string, role id): cgraph's edge identity within a source / target pair.
|
|
1469
|
+
* @returns the handle
|
|
1470
|
+
*/
|
|
1471
|
+
private keyColumn(): ColumnHandle {
|
|
1472
|
+
if (this.keyHandle === INVALID_INDEX) {
|
|
1473
|
+
this.keyHandle = this.declare("edge", {
|
|
1474
|
+
name: KEY_ATTRIBUTE,
|
|
1475
|
+
dtype: "string",
|
|
1476
|
+
nullable: true,
|
|
1477
|
+
role: "id",
|
|
1478
|
+
origin: { ...DOT_ORIGIN, id: KEY_ATTRIBUTE },
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
return this.keyHandle;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
/**
|
|
1485
|
+
* The source port column (string, role sourcePort).
|
|
1486
|
+
* @returns the handle
|
|
1487
|
+
*/
|
|
1488
|
+
private sourcePortColumn(): ColumnHandle {
|
|
1489
|
+
if (this.sourcePortHandle === INVALID_INDEX) {
|
|
1490
|
+
this.sourcePortHandle = this.declare("edge", {
|
|
1491
|
+
name: SOURCE_PORT_COLUMN,
|
|
1492
|
+
dtype: "string",
|
|
1493
|
+
nullable: true,
|
|
1494
|
+
role: "sourcePort",
|
|
1495
|
+
origin: DOT_ORIGIN,
|
|
1496
|
+
});
|
|
1497
|
+
}
|
|
1498
|
+
return this.sourcePortHandle;
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
/**
|
|
1502
|
+
* The target port column (string, role targetPort).
|
|
1503
|
+
* @returns the handle
|
|
1504
|
+
*/
|
|
1505
|
+
private targetPortColumn(): ColumnHandle {
|
|
1506
|
+
if (this.targetPortHandle === INVALID_INDEX) {
|
|
1507
|
+
this.targetPortHandle = this.declare("edge", {
|
|
1508
|
+
name: TARGET_PORT_COLUMN,
|
|
1509
|
+
dtype: "string",
|
|
1510
|
+
nullable: true,
|
|
1511
|
+
role: "targetPort",
|
|
1512
|
+
origin: DOT_ORIGIN,
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
return this.targetPortHandle;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/**
|
|
1519
|
+
* The inferred-column writer of an attribute name in a domain (design section 5.1: the column's
|
|
1520
|
+
* dtype follows the text grammar per column; a caller's column of the name receives parsed
|
|
1521
|
+
* values through the sink's own inference).
|
|
1522
|
+
* @param domain - node or edge
|
|
1523
|
+
* @param name - the attribute name
|
|
1524
|
+
* @returns the writer
|
|
1525
|
+
*/
|
|
1526
|
+
private textWriter(domain: "node" | "edge", name: string): TextCellWriter {
|
|
1527
|
+
const writers = domain === "node" ? this.nodeWriters : this.edgeWriters;
|
|
1528
|
+
let writer = writers.get(name);
|
|
1529
|
+
if (writer === undefined) {
|
|
1530
|
+
writer = new TextCellWriter(name, domain, this.sink, this.report);
|
|
1531
|
+
writers.set(name, writer);
|
|
1532
|
+
}
|
|
1533
|
+
return writer;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
/**
|
|
1537
|
+
* Declare one of the importer's columns on the sink through the shared design section 5.6
|
|
1538
|
+
* rule: a caller's sink that already holds the role gets the column without it (reported), one
|
|
1539
|
+
* that holds the name with another shape gets it renamed `<name>#<id>` (reported).
|
|
1540
|
+
* @param domain - node or edge
|
|
1541
|
+
* @param decl - the declaration
|
|
1542
|
+
* @returns the handle
|
|
1543
|
+
*/
|
|
1544
|
+
private declare(domain: "node" | "edge", decl: ColumnDecl): ColumnHandle {
|
|
1545
|
+
const withId: ColumnDecl =
|
|
1546
|
+
decl.origin?.id === undefined ? { ...decl, origin: { ...decl.origin, id: decl.name } } : decl;
|
|
1547
|
+
return declareResolved(this.sink, domain, withId, this.report, { element: decl.name }).handle;
|
|
1548
|
+
}
|
|
1549
|
+
}
|