@eriknaslund/skiss 0.4.0 → 0.5.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/README.md +6 -3
- package/dist/cli.js +309 -25
- package/dist/index.d.ts +12 -4
- package/dist/index.js +209 -19
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -41,6 +41,7 @@ Skiss is text you can type while someone talks. A field is one line. A change is
|
|
|
41
41
|
- **What you leave out means "not yet".** Never "unknown", never "none". A sketch is silent about what you do not know.
|
|
42
42
|
- **`@Catalog` says which system owns a thing.** On a class, or on a single field when the box is really a view over two systems.
|
|
43
43
|
- **`~` and `=` are two different statements.** `~` says two classes are about the same thing. `=` says which field joins to which. The first is a domain conversation, the second an integration conversation.
|
|
44
|
+
- **`<` inherits, once.** `Jedi < Character` on the class line: the child has everything the parent has and adds its own. One parent, no mixins; it compiles to `is_a`.
|
|
44
45
|
- **`#` describes, `?` doubts.** A description ends up in generated documentation. A doubt never does.
|
|
45
46
|
- **Every line stands on its own.** A half-typed line breaks that line and nothing else, so the diagram keeps rendering while you type.
|
|
46
47
|
- **Everything you can write compiles to valid LinkML.** Skiss is a strict subset. Nothing is lost on the way up.
|
|
@@ -62,9 +63,11 @@ Or without installing: `npx @eriknaslund/skiss diagram model.skiss`.
|
|
|
62
63
|
|
|
63
64
|
`skiss diagram` writes a Mermaid class diagram to standard output; `-o diagram.mmd` writes it to a file, `--notes` includes the `?` doubts as notes, and `-` reads standard input. Anything the file gets wrong is reported on standard error, one line each, and the diagram is still drawn from the lines that parse. `--strict` makes that a non-zero exit. `skiss --help` lists the rest.
|
|
64
65
|
|
|
66
|
+
`skiss render model.skiss -o model.svg` writes a picture rather than the Mermaid text. A class diagram is laid out in a browser, so this one command needs a browser: it delegates to the official [Mermaid CLI](https://github.com/mermaid-js/mermaid-cli), which you install yourself with `npm install -g @mermaid-js/mermaid-cli`. Nothing in the package or the library depends on it, and `skiss render` says that line and exits 2 when it is not there. The format is the extension of `-o` — `.svg` or `.png` — and `--format svg|png` overrides it; with no `-o` the SVG goes to standard output, and a PNG always needs an `-o`. `--scale` is what the PNG is rasterised at and defaults to 2; `--notes`, `--strict` and the diagnostics work as they do for `diagram`. When `mmdc` itself fails, its exit status is the command's.
|
|
67
|
+
|
|
65
68
|
`skiss compile model.skiss` writes a LinkML schema instead, as YAML on standard output. `--json` writes the same schema as JSON, `--name` sets the schema name (it defaults to the file name without its extension, and to `sketch` when reading standard input), and `-o`, `-`, `--strict` and the diagnostics work exactly as they do for `diagram`.
|
|
66
69
|
|
|
67
|
-
`skiss import schema.linkml.yaml` goes the other way: it reads a LinkML schema, YAML or JSON, and writes Skiss. What the projection could not carry —
|
|
70
|
+
`skiss import schema.linkml.yaml` goes the other way: it reads a LinkML schema, YAML or JSON, and writes Skiss. What the projection could not carry — patterns, `required`, `mixins`, the names it had to convert — is one line on standard error before the diagnostics, because a sketch is a smaller language than LinkML and that line says by how much. `-o`, `-` and the diagnostics work as they do for `compile`; `--strict` exits 1 when the projected sketch has a diagnostic, and also when anything was dropped. An input that is not a schema at all — not YAML, or YAML no schema can be read out of — produces no sketch, says why on standard error, and exits 2, the code every command uses for unreadable input.
|
|
68
71
|
|
|
69
72
|
`npm install @eriknaslund/skiss` also gives you the library: `compile`, `formatDiagnostic`, `parse`, `resolve`, `toLinkML`, `serialize`, `toMermaid`, `toSkiss`, `fromLinkML`, `formatDropped`, `importLinkML` and `VERSION`, browser-safe.
|
|
70
73
|
|
|
@@ -84,9 +87,9 @@ Editors that render Skiss live are separate projects built on this package. This
|
|
|
84
87
|
|
|
85
88
|
## Status
|
|
86
89
|
|
|
87
|
-
0.
|
|
90
|
+
0.5.0, published on npm as [`@eriknaslund/skiss`](https://www.npmjs.com/package/@eriknaslund/skiss). The language parses, resolves and diagnoses; `skiss diagram` draws it, `skiss compile` writes LinkML that the real LinkML toolchain validates in CI, and `skiss import` reads a LinkML schema back into a sketch. The library exports the same in both directions and is browser-safe.
|
|
88
91
|
|
|
89
|
-
The [specification](docs/SPEC.md) is at 0.
|
|
92
|
+
The [specification](docs/SPEC.md) is at 0.3 and moves when the language does, which is not with every release. Not built: editing a projected schema so that what Skiss cannot express survives the edit (SPEC §8.1). [CHANGELOG.md](CHANGELOG.md) is what changed when.
|
|
90
93
|
|
|
91
94
|
## License
|
|
92
95
|
|
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,92 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { spawnSync } from "child_process";
|
|
5
|
+
import { constants } from "fs";
|
|
6
|
+
import { access, mkdtemp, readFile, rm, writeFile } from "fs/promises";
|
|
7
|
+
import { tmpdir } from "os";
|
|
8
|
+
import { basename, delimiter, extname, join } from "path";
|
|
6
9
|
import { parseArgs } from "util";
|
|
7
10
|
|
|
11
|
+
// src/inheritance.ts
|
|
12
|
+
function inheritance(classes) {
|
|
13
|
+
const declared = /* @__PURE__ */ new Map();
|
|
14
|
+
for (const cls of classes) {
|
|
15
|
+
if (!declared.has(cls.name.text)) declared.set(cls.name.text, cls);
|
|
16
|
+
}
|
|
17
|
+
const circles = /* @__PURE__ */ new Map();
|
|
18
|
+
const parentOf = (cls) => {
|
|
19
|
+
if (cls.parent === void 0 || circles.has(cls)) return void 0;
|
|
20
|
+
return declared.get(cls.parent.text);
|
|
21
|
+
};
|
|
22
|
+
for (const cls of declared.values()) {
|
|
23
|
+
const path = [cls];
|
|
24
|
+
const seen = new Set(path);
|
|
25
|
+
for (let next = parentOf(cls); next !== void 0; next = parentOf(next)) {
|
|
26
|
+
if (seen.has(next)) {
|
|
27
|
+
const circle = path.slice(path.indexOf(next));
|
|
28
|
+
let closer = circle[0];
|
|
29
|
+
for (const member2 of circle) {
|
|
30
|
+
if (member2.line > closer.line) closer = member2;
|
|
31
|
+
}
|
|
32
|
+
const at = circle.indexOf(closer);
|
|
33
|
+
circles.set(closer, [...circle.slice(at), ...circle.slice(0, at)]);
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
path.push(next);
|
|
37
|
+
seen.add(next);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const ancestors = (cls) => {
|
|
41
|
+
const out = [];
|
|
42
|
+
const seen = /* @__PURE__ */ new Set([cls]);
|
|
43
|
+
for (let next = parentOf(cls); next !== void 0; next = parentOf(next)) {
|
|
44
|
+
if (seen.has(next)) break;
|
|
45
|
+
out.push(next);
|
|
46
|
+
seen.add(next);
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
};
|
|
50
|
+
const find = (cls, matches) => {
|
|
51
|
+
for (const from of ancestors(cls)) {
|
|
52
|
+
const field = from.fields.find(matches);
|
|
53
|
+
if (field !== void 0) return { field, from };
|
|
54
|
+
}
|
|
55
|
+
return void 0;
|
|
56
|
+
};
|
|
57
|
+
return {
|
|
58
|
+
ancestors,
|
|
59
|
+
inherited: (cls, fieldName) => find(cls, (field) => field.name.text === fieldName),
|
|
60
|
+
inheritedIdentifier: (cls) => find(cls, (field) => field.identifier),
|
|
61
|
+
circle: (cls) => circles.get(cls)
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function sameField(child, inheritedField) {
|
|
65
|
+
return meaning(child) === meaning(inheritedField);
|
|
66
|
+
}
|
|
67
|
+
var meaning = (field) => JSON.stringify([
|
|
68
|
+
field.name.text,
|
|
69
|
+
field.identifier,
|
|
70
|
+
typeMeaning(field),
|
|
71
|
+
field.system?.text,
|
|
72
|
+
field.joinsTo === void 0 ? void 0 : [field.joinsTo.className.text, field.joinsTo.fieldName.text],
|
|
73
|
+
field.description,
|
|
74
|
+
field.note
|
|
75
|
+
]);
|
|
76
|
+
function typeMeaning(field) {
|
|
77
|
+
const type = field.type;
|
|
78
|
+
if (type === void 0) return void 0;
|
|
79
|
+
switch (type.kind) {
|
|
80
|
+
case "primitive":
|
|
81
|
+
return [type.kind, type.name, type.many];
|
|
82
|
+
case "class":
|
|
83
|
+
case "unknown":
|
|
84
|
+
return [type.kind, type.name.text, type.many];
|
|
85
|
+
case "enum":
|
|
86
|
+
return [type.kind, type.values.map((v) => v.text), type.many];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
8
90
|
// src/parse.ts
|
|
9
91
|
var isWs = (c) => c === " " || c === " ";
|
|
10
92
|
var trimWs = (s) => s.replace(/^[ \t]+/, "").replace(/[ \t]+$/, "");
|
|
@@ -28,7 +110,7 @@ function primitiveFor(word) {
|
|
|
28
110
|
return Object.hasOwn(PRIMITIVES, word) ? PRIMITIVES[word] : void 0;
|
|
29
111
|
}
|
|
30
112
|
var FIELD_ORDER = "name, `*`, `: type`, `@System`, `= Class.field`, then `#` and `?`";
|
|
31
|
-
var CLASS_ORDER = "name, `@System`, `~ Class`, then `#` and `?`";
|
|
113
|
+
var CLASS_ORDER = "name, `< Parent`, `@System`, `~ Class`, then `#` and `?`";
|
|
32
114
|
function parse(source) {
|
|
33
115
|
const classes = [];
|
|
34
116
|
const diagnostics = [];
|
|
@@ -115,7 +197,7 @@ function splitTrailer(text) {
|
|
|
115
197
|
return trailer;
|
|
116
198
|
}
|
|
117
199
|
var WORD = /[\p{L}\p{N}_-]+/uy;
|
|
118
|
-
var OPERATORS = ["*", ":", "@", "~", "=", ".", "|", "["];
|
|
200
|
+
var OPERATORS = ["*", ":", "@", "~", "=", ".", "|", "<", ",", "["];
|
|
119
201
|
function tokenize(head) {
|
|
120
202
|
const tokens = [];
|
|
121
203
|
let i = 0;
|
|
@@ -177,6 +259,13 @@ function parseClassLine(text, line) {
|
|
|
177
259
|
);
|
|
178
260
|
}
|
|
179
261
|
const node = { name: name(head, line), line };
|
|
262
|
+
const lt = peek(c);
|
|
263
|
+
if (lt?.kind === "<") {
|
|
264
|
+
take(c);
|
|
265
|
+
const parent = parseParent(c, lt);
|
|
266
|
+
if (!parent.ok) return parent;
|
|
267
|
+
node.parent = parent.value;
|
|
268
|
+
}
|
|
180
269
|
const at = peek(c);
|
|
181
270
|
if (at?.kind === "@") {
|
|
182
271
|
take(c);
|
|
@@ -306,6 +395,31 @@ function parseFieldLine(text, line) {
|
|
|
306
395
|
if (trailer.note !== void 0) node.note = trailer.note;
|
|
307
396
|
return { ok: true, value: node };
|
|
308
397
|
}
|
|
398
|
+
function parseParent(c, lt) {
|
|
399
|
+
const parent = take(c);
|
|
400
|
+
if (parent === void 0 || parent.kind !== "word") {
|
|
401
|
+
return fail(
|
|
402
|
+
"E_UNPARSABLE",
|
|
403
|
+
"Expected a class name after `<`, e.g. `Jedi < Character`",
|
|
404
|
+
c.line,
|
|
405
|
+
parent ?? afterSpan(lt)
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
if (!CLASS_NAME.test(parent.text)) {
|
|
409
|
+
const message = Object.hasOwn(PRIMITIVES, parent.text) ? `\`${parent.text}\` is a primitive, not a class; a parent is a class` : `The class after \`<\` must be UpperCamelCase: \`${parent.text}\``;
|
|
410
|
+
return fail("E_BAD_NAME", message, c.line, parent);
|
|
411
|
+
}
|
|
412
|
+
const next = peek(c);
|
|
413
|
+
if (next?.kind === "," || next?.kind === "<") {
|
|
414
|
+
return fail(
|
|
415
|
+
"E_UNPARSABLE",
|
|
416
|
+
"A class has one parent; `<` takes a single class name",
|
|
417
|
+
c.line,
|
|
418
|
+
next
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
return { ok: true, value: name(parent, c.line) };
|
|
422
|
+
}
|
|
309
423
|
function parseSystem(c, at) {
|
|
310
424
|
const system = take(c);
|
|
311
425
|
if (system === void 0 || system.kind !== "word") {
|
|
@@ -415,7 +529,7 @@ function parseType(c, colon) {
|
|
|
415
529
|
);
|
|
416
530
|
}
|
|
417
531
|
function unexpected(token, line, kind, order) {
|
|
418
|
-
const modifiers = kind === "field" ? ["*", ":", "@", "="] : ["@", "~"];
|
|
532
|
+
const modifiers = kind === "field" ? ["*", ":", "@", "="] : ["<", "@", "~"];
|
|
419
533
|
if (modifiers.includes(token.kind)) {
|
|
420
534
|
return fail(
|
|
421
535
|
"E_UNPARSABLE",
|
|
@@ -424,10 +538,10 @@ function unexpected(token, line, kind, order) {
|
|
|
424
538
|
token
|
|
425
539
|
);
|
|
426
540
|
}
|
|
427
|
-
if (token.
|
|
541
|
+
if (token.kind === "<") {
|
|
428
542
|
return fail(
|
|
429
543
|
"E_UNPARSABLE",
|
|
430
|
-
"`<`
|
|
544
|
+
"`<` marks inheritance and belongs on the class line: `Child < Parent`",
|
|
431
545
|
line,
|
|
432
546
|
token
|
|
433
547
|
);
|
|
@@ -456,10 +570,14 @@ function resolve(doc) {
|
|
|
456
570
|
};
|
|
457
571
|
const classes = out.classes;
|
|
458
572
|
const declared = new Set(classes.map((c) => c.name.text));
|
|
459
|
-
const
|
|
573
|
+
const found = [];
|
|
460
574
|
const undeclared = /* @__PURE__ */ new Map();
|
|
575
|
+
const tree = inheritance(classes);
|
|
461
576
|
const warn = (code, message, at) => {
|
|
462
|
-
|
|
577
|
+
found.push({ severity: "warning", code, message, line: at.line, col: at.col, end: at.end });
|
|
578
|
+
};
|
|
579
|
+
const error = (code, message, at) => {
|
|
580
|
+
found.push({ severity: "error", code, message, line: at.line, col: at.col, end: at.end });
|
|
463
581
|
};
|
|
464
582
|
const reference = (name2) => {
|
|
465
583
|
if (declared.has(name2.text)) return true;
|
|
@@ -480,9 +598,25 @@ function resolve(doc) {
|
|
|
480
598
|
cls.name
|
|
481
599
|
);
|
|
482
600
|
}
|
|
601
|
+
const parent = cls.parent;
|
|
602
|
+
if (parent !== void 0) {
|
|
603
|
+
reference(parent);
|
|
604
|
+
const circle = tree.circle(cls);
|
|
605
|
+
if (circle !== void 0) {
|
|
606
|
+
const through = circle.slice(1).map((member2) => `\`${member2.name.text}\``);
|
|
607
|
+
const path = through.length === 0 ? "" : ` through ${through.join(", ")}`;
|
|
608
|
+
error(
|
|
609
|
+
"E_INHERITANCE_CYCLE",
|
|
610
|
+
`class \`${cls.name.text}\` inherits from itself${path}; this \`<\` is not carried`,
|
|
611
|
+
parent
|
|
612
|
+
);
|
|
613
|
+
cls.parent = void 0;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
483
616
|
if (cls.similarTo !== void 0) reference(cls.similarTo);
|
|
484
617
|
const firstField = /* @__PURE__ */ new Map();
|
|
485
|
-
|
|
618
|
+
const inheritedIdentifier = tree.inheritedIdentifier(cls);
|
|
619
|
+
let identifier = inheritedIdentifier?.field;
|
|
486
620
|
for (const field of cls.fields) {
|
|
487
621
|
const earlierField = firstField.get(field.name.text);
|
|
488
622
|
if (earlierField === void 0) firstField.set(field.name.text, field);
|
|
@@ -496,14 +630,23 @@ function resolve(doc) {
|
|
|
496
630
|
if (field.identifier) {
|
|
497
631
|
if (identifier === void 0) identifier = field;
|
|
498
632
|
else {
|
|
633
|
+
const from = identifier === inheritedIdentifier?.field ? ` inherits the identifier \`${identifier.name.text}\` from class \`${inheritedIdentifier.from.name.text}\`, line` : ` already has the identifier \`${identifier.name.text}\` on line`;
|
|
499
634
|
warn(
|
|
500
635
|
"W_MULTIPLE_IDENTIFIERS",
|
|
501
|
-
`\`*\` on \`${field.name.text}\` is ignored: class \`${cls.name.text}
|
|
636
|
+
`\`*\` on \`${field.name.text}\` is ignored: class \`${cls.name.text}\`${from} ${identifier.line}`,
|
|
502
637
|
field.identifierAt ?? field.name
|
|
503
638
|
);
|
|
504
639
|
field.identifier = false;
|
|
505
640
|
}
|
|
506
641
|
}
|
|
642
|
+
const replaced = tree.inherited(cls, field.name.text);
|
|
643
|
+
if (replaced !== void 0 && sameField(field, replaced.field)) {
|
|
644
|
+
warn(
|
|
645
|
+
"W_REDUNDANT_OVERRIDE",
|
|
646
|
+
`field \`${field.name.text}\` is identical to the one inherited from class \`${replaced.from.name.text}\` on line ${replaced.field.line}`,
|
|
647
|
+
field.name
|
|
648
|
+
);
|
|
649
|
+
}
|
|
507
650
|
if (field.type?.kind === "unknown") {
|
|
508
651
|
const word = field.type.name.text;
|
|
509
652
|
const suggestion = closestTypeWord(word);
|
|
@@ -541,8 +684,8 @@ function resolve(doc) {
|
|
|
541
684
|
}
|
|
542
685
|
}
|
|
543
686
|
const present2 = new Set(out.diagnostics.map(key));
|
|
544
|
-
for (const
|
|
545
|
-
if (!present2.has(key(
|
|
687
|
+
for (const d of found) {
|
|
688
|
+
if (!present2.has(key(d))) out.diagnostics.push(d);
|
|
546
689
|
}
|
|
547
690
|
out.diagnostics.sort((a, b) => a.line - b.line);
|
|
548
691
|
out.undeclared = [...undeclared.values()].map((n) => ({ ...n }));
|
|
@@ -573,6 +716,7 @@ function copyField(f) {
|
|
|
573
716
|
}
|
|
574
717
|
function copyClass(c) {
|
|
575
718
|
const out = { ...c, name: copyName(c.name), fields: c.fields.map(copyField) };
|
|
719
|
+
if (c.parent !== void 0) out.parent = copyName(c.parent);
|
|
576
720
|
if (c.system !== void 0) out.system = copyName(c.system);
|
|
577
721
|
if (c.similarTo !== void 0) out.similarTo = copyName(c.similarTo);
|
|
578
722
|
return out;
|
|
@@ -751,6 +895,7 @@ function buildClass(cls, mappingPrefix, enums) {
|
|
|
751
895
|
const { node, fields } = cls;
|
|
752
896
|
const out = {};
|
|
753
897
|
if (node.description !== void 0) out.description = node.description;
|
|
898
|
+
if (node.parent !== void 0) out.is_a = node.parent.text;
|
|
754
899
|
const annotations = {};
|
|
755
900
|
if (node.system !== void 0) annotations.system = node.system.text;
|
|
756
901
|
if (node.note !== void 0) annotations.note = node.note;
|
|
@@ -833,6 +978,7 @@ function toMermaid(doc, opts) {
|
|
|
833
978
|
}
|
|
834
979
|
for (const cls of resolved.classes) {
|
|
835
980
|
const from = cls.name.text;
|
|
981
|
+
if (cls.parent !== void 0) lines.push(` ${cls.parent.text} <|-- ${from}`);
|
|
836
982
|
if (cls.similarTo !== void 0) lines.push(` ${from} ..> ${cls.similarTo.text} : similar`);
|
|
837
983
|
for (const field of cls.fields) {
|
|
838
984
|
const type = field.type;
|
|
@@ -939,7 +1085,7 @@ function toSkiss(doc) {
|
|
|
939
1085
|
`;
|
|
940
1086
|
}
|
|
941
1087
|
function classBlock(cls) {
|
|
942
|
-
const head = cls.name.text + (cls.system === void 0 ? "" : ` @${cls.system.text}`) + (cls.similarTo === void 0 ? "" : ` ~ ${cls.similarTo.text}`);
|
|
1088
|
+
const head = cls.name.text + (cls.parent === void 0 ? "" : ` < ${cls.parent.text}`) + (cls.system === void 0 ? "" : ` @${cls.system.text}`) + (cls.similarTo === void 0 ? "" : ` ~ ${cls.similarTo.text}`);
|
|
943
1089
|
return [withTrailer(head, cls), ...cls.fields.map(fieldLine)].join("\n");
|
|
944
1090
|
}
|
|
945
1091
|
function fieldLine(field) {
|
|
@@ -993,7 +1139,14 @@ var PRIMITIVE_RANGE = {
|
|
|
993
1139
|
uri: "uri",
|
|
994
1140
|
uriorcurie: "uri"
|
|
995
1141
|
};
|
|
996
|
-
var CLASS_KEYS = /* @__PURE__ */ new Set([
|
|
1142
|
+
var CLASS_KEYS = /* @__PURE__ */ new Set([
|
|
1143
|
+
"description",
|
|
1144
|
+
"is_a",
|
|
1145
|
+
"annotations",
|
|
1146
|
+
"close_mappings",
|
|
1147
|
+
"attributes",
|
|
1148
|
+
"slots"
|
|
1149
|
+
]);
|
|
997
1150
|
var SLOT_KEYS = /* @__PURE__ */ new Set(["description", "annotations", "range", "multivalued", "identifier"]);
|
|
998
1151
|
var CLASS_TAGS = /* @__PURE__ */ new Set(["system", "note", "undeclared"]);
|
|
999
1152
|
var SLOT_TAGS = /* @__PURE__ */ new Set(["system", "note", "joins_to"]);
|
|
@@ -1036,7 +1189,7 @@ function fromLinkML(schema) {
|
|
|
1036
1189
|
for (const key2 of Object.keys(classDefs)) {
|
|
1037
1190
|
const names = /* @__PURE__ */ new Map();
|
|
1038
1191
|
const taken = /* @__PURE__ */ new Set();
|
|
1039
|
-
for (const slot of
|
|
1192
|
+
for (const slot of fieldKeysOf(key2)) {
|
|
1040
1193
|
names.set(slot, unique(toFieldName(slot), taken));
|
|
1041
1194
|
}
|
|
1042
1195
|
fieldNames.set(key2, names);
|
|
@@ -1106,6 +1259,12 @@ function fromLinkML(schema) {
|
|
|
1106
1259
|
const note = oneLine(rawNote);
|
|
1107
1260
|
if (note !== void 0) node.note = note;
|
|
1108
1261
|
} else if (rawNote !== void 0) report("annotation", name2, notText("note", rawNote));
|
|
1262
|
+
const parent = stringOf(get(def, "is_a"));
|
|
1263
|
+
if (parent !== void 0) {
|
|
1264
|
+
const parentName = Object.hasOwn(classDefs, parent) ? classNameOf(parent) : toClassName(parent);
|
|
1265
|
+
node.parent = at0(parentName);
|
|
1266
|
+
reference(name2, parent, parentName);
|
|
1267
|
+
}
|
|
1109
1268
|
const mappings = stringList(get(def, "close_mappings"));
|
|
1110
1269
|
const first = mappings[0];
|
|
1111
1270
|
const target = first === void 0 ? "" : localName(first);
|
|
@@ -1128,6 +1287,9 @@ function fromLinkML(schema) {
|
|
|
1128
1287
|
}
|
|
1129
1288
|
}
|
|
1130
1289
|
const block = get(def, other);
|
|
1290
|
+
if (other === "is_a" && present(block) && stringOf(block) === void 0) {
|
|
1291
|
+
report("is_a", name2, `${shown(block)} is not a class name`);
|
|
1292
|
+
}
|
|
1131
1293
|
if (other === "attributes" && present(block) && asRecord(block) === void 0) {
|
|
1132
1294
|
report("attributes", name2, `${shown(block)} is not a map`);
|
|
1133
1295
|
}
|
|
@@ -1142,10 +1304,12 @@ function fromLinkML(schema) {
|
|
|
1142
1304
|
report("slot_usage", name2);
|
|
1143
1305
|
continue;
|
|
1144
1306
|
}
|
|
1307
|
+
const written = slotKeysOf(def);
|
|
1308
|
+
const inherited = inheritedSlots(key2);
|
|
1145
1309
|
for (const slot of Object.keys(usage)) {
|
|
1146
|
-
if (
|
|
1310
|
+
if (written.includes(slot)) {
|
|
1147
1311
|
report("slot_usage", `${name2}.${fieldNameOf(key2, slot)}`);
|
|
1148
|
-
} else {
|
|
1312
|
+
} else if (!inherited.has(slot)) {
|
|
1149
1313
|
report("slot_usage", name2, `\`${slot}\` is inherited and not in the sketch`);
|
|
1150
1314
|
}
|
|
1151
1315
|
}
|
|
@@ -1153,9 +1317,10 @@ function fromLinkML(schema) {
|
|
|
1153
1317
|
}
|
|
1154
1318
|
report(other, name2);
|
|
1155
1319
|
}
|
|
1156
|
-
|
|
1320
|
+
const own = slotKeysOf(def);
|
|
1321
|
+
for (const slot of fieldKeysOf(key2)) {
|
|
1157
1322
|
const attributes = asRecord(get(def, "attributes")) ?? {};
|
|
1158
|
-
const rawSlot = Object.hasOwn(attributes, slot) ? get(attributes, slot) : get(slotDefs, slot);
|
|
1323
|
+
const rawSlot = !own.includes(slot) ? get(asRecord(get(def, "slot_usage")) ?? {}, slot) : Object.hasOwn(attributes, slot) ? get(attributes, slot) : get(slotDefs, slot);
|
|
1159
1324
|
const slotDef = asRecord(rawSlot);
|
|
1160
1325
|
if (slotDef === void 0 && present(rawSlot)) {
|
|
1161
1326
|
report("slot", `${name2}.${fieldNameOf(key2, slot)}`, "is not a slot definition");
|
|
@@ -1292,6 +1457,34 @@ function fromLinkML(schema) {
|
|
|
1292
1457
|
report("narrowed", element, `\`${range}\` is not a Skiss type; it falls back to string`);
|
|
1293
1458
|
return { kind: "unknown", name: at0(word), many };
|
|
1294
1459
|
}
|
|
1460
|
+
function parentKeyOf(classKey) {
|
|
1461
|
+
const def = asRecord(get(classDefs, classKey)) ?? {};
|
|
1462
|
+
const parent = stringOf(get(def, "is_a"));
|
|
1463
|
+
if (parent === void 0 || !Object.hasOwn(classDefs, parent)) return void 0;
|
|
1464
|
+
return parent;
|
|
1465
|
+
}
|
|
1466
|
+
function inheritedSlots(classKey) {
|
|
1467
|
+
const out = /* @__PURE__ */ new Set();
|
|
1468
|
+
const seen = /* @__PURE__ */ new Set([classKey]);
|
|
1469
|
+
let key2 = parentKeyOf(classKey);
|
|
1470
|
+
while (key2 !== void 0 && !seen.has(key2)) {
|
|
1471
|
+
seen.add(key2);
|
|
1472
|
+
for (const slot of slotKeysOf(asRecord(get(classDefs, key2)) ?? {})) out.add(slot);
|
|
1473
|
+
key2 = parentKeyOf(key2);
|
|
1474
|
+
}
|
|
1475
|
+
return out;
|
|
1476
|
+
}
|
|
1477
|
+
function fieldKeysOf(classKey) {
|
|
1478
|
+
const def = asRecord(get(classDefs, classKey)) ?? {};
|
|
1479
|
+
const keys = slotKeysOf(def);
|
|
1480
|
+
const usage = asRecord(get(def, "slot_usage"));
|
|
1481
|
+
if (usage === void 0) return keys;
|
|
1482
|
+
const inherited = inheritedSlots(classKey);
|
|
1483
|
+
for (const slot of Object.keys(usage)) {
|
|
1484
|
+
if (inherited.has(slot) && !keys.includes(slot)) keys.push(slot);
|
|
1485
|
+
}
|
|
1486
|
+
return keys;
|
|
1487
|
+
}
|
|
1295
1488
|
function listedSomewhere(slot) {
|
|
1296
1489
|
for (const key2 of Object.keys(classDefs)) {
|
|
1297
1490
|
const def = asRecord(get(classDefs, key2)) ?? {};
|
|
@@ -1474,7 +1667,7 @@ function notYAML(error) {
|
|
|
1474
1667
|
}
|
|
1475
1668
|
|
|
1476
1669
|
// src/index.ts
|
|
1477
|
-
var VERSION = "0.
|
|
1670
|
+
var VERSION = "0.5.0";
|
|
1478
1671
|
|
|
1479
1672
|
// src/cli.ts
|
|
1480
1673
|
var HELP = `Usage: skiss <command> [options]
|
|
@@ -1483,15 +1676,22 @@ Commands:
|
|
|
1483
1676
|
diagram <file> Write a Mermaid class diagram for a .skiss file.
|
|
1484
1677
|
compile <file> Write a LinkML schema for a .skiss file.
|
|
1485
1678
|
import <file> Write Skiss for a LinkML schema file, YAML or JSON.
|
|
1679
|
+
render <file> Write an SVG or a PNG of the class diagram, through
|
|
1680
|
+
the Mermaid CLI (mmdc), which must be installed.
|
|
1486
1681
|
<file> may be - to read standard input.
|
|
1487
1682
|
|
|
1488
1683
|
Options:
|
|
1489
1684
|
-o, --output <path> Write the output to <path> instead of standard output
|
|
1490
|
-
--notes diagram: emit \`? text\` doubts as Mermaid notes
|
|
1685
|
+
--notes diagram, render: emit \`? text\` doubts as Mermaid notes
|
|
1491
1686
|
--json compile: write the schema as JSON instead of YAML
|
|
1492
1687
|
--name <name> compile: the schema name. Defaults to the file's
|
|
1493
1688
|
basename without its extension, or \`sketch\` for
|
|
1494
1689
|
standard input.
|
|
1690
|
+
--format <fmt> render: svg or png. Defaults to the extension of
|
|
1691
|
+
-o, and to svg when there is no -o. png needs an -o:
|
|
1692
|
+
only the SVG can go to standard output.
|
|
1693
|
+
--scale <n> render: the scale mmdc rasterises at. Defaults to 2
|
|
1694
|
+
for png, and to mmdc's own default for svg.
|
|
1495
1695
|
--strict Exit 1 when the input has any diagnostic; for import,
|
|
1496
1696
|
also when the projection dropped anything
|
|
1497
1697
|
-h, --help Show this help and exit
|
|
@@ -1508,8 +1708,11 @@ Exit codes:
|
|
|
1508
1708
|
0 the output was produced
|
|
1509
1709
|
1 --strict and the input has diagnostics (the output is still written)
|
|
1510
1710
|
2 usage error, missing file, or unreadable input: for import, a text no
|
|
1511
|
-
schema could be read out of, which produces no sketch at all
|
|
1711
|
+
schema could be read out of, which produces no sketch at all; for
|
|
1712
|
+
render, the Mermaid CLI not being installed
|
|
1512
1713
|
70 internal error, a bug in skiss
|
|
1714
|
+
|
|
1715
|
+
render exits with mmdc's own status when mmdc ran and failed.
|
|
1513
1716
|
`;
|
|
1514
1717
|
var EXIT_OK = 0;
|
|
1515
1718
|
var EXIT_STRICT = 1;
|
|
@@ -1518,7 +1721,8 @@ var EXIT_INTERNAL = 70;
|
|
|
1518
1721
|
var COMMANDS = {
|
|
1519
1722
|
diagram: ["output", "notes", "strict"],
|
|
1520
1723
|
compile: ["output", "json", "name", "strict"],
|
|
1521
|
-
import: ["output", "strict"]
|
|
1724
|
+
import: ["output", "strict"],
|
|
1725
|
+
render: ["output", "notes", "format", "scale", "strict"]
|
|
1522
1726
|
};
|
|
1523
1727
|
var isCommand = (text) => Object.hasOwn(COMMANDS, text);
|
|
1524
1728
|
var UsageError = class extends Error {
|
|
@@ -1540,6 +1744,8 @@ async function main(argv) {
|
|
|
1540
1744
|
notes: { type: "boolean" },
|
|
1541
1745
|
json: { type: "boolean" },
|
|
1542
1746
|
name: { type: "string" },
|
|
1747
|
+
format: { type: "string" },
|
|
1748
|
+
scale: { type: "string" },
|
|
1543
1749
|
strict: { type: "boolean" },
|
|
1544
1750
|
help: { type: "boolean", short: "h" },
|
|
1545
1751
|
version: { type: "boolean" }
|
|
@@ -1569,8 +1775,16 @@ async function main(argv) {
|
|
|
1569
1775
|
}
|
|
1570
1776
|
}
|
|
1571
1777
|
const source = file === "-" ? await readStdin() : await readSource(file);
|
|
1572
|
-
const { output, diagnostics, dropped } = runCommand(command, source, file, values);
|
|
1573
1778
|
const label = file === "-" ? "<stdin>" : file;
|
|
1779
|
+
if (command === "render") {
|
|
1780
|
+
const picture = compile(source, { target: "mermaid", notes: values.notes });
|
|
1781
|
+
for (const d of picture.diagnostics) process.stderr.write(`${formatDiagnostic(d, label)}
|
|
1782
|
+
`);
|
|
1783
|
+
const status = await render(picture.output, values);
|
|
1784
|
+
if (status !== EXIT_OK) return status;
|
|
1785
|
+
return values.strict === true && picture.diagnostics.length > 0 ? EXIT_STRICT : EXIT_OK;
|
|
1786
|
+
}
|
|
1787
|
+
const { output, diagnostics, dropped } = runCommand(command, source, file, values);
|
|
1574
1788
|
const report = formatDropped(dropped);
|
|
1575
1789
|
if (report !== "") process.stderr.write(`${report}
|
|
1576
1790
|
`);
|
|
@@ -1625,6 +1839,76 @@ async function writeOutput(path, text) {
|
|
|
1625
1839
|
throw new UsageError(`cannot write \`${path}\`: ${reason(error)}`);
|
|
1626
1840
|
}
|
|
1627
1841
|
}
|
|
1842
|
+
var MMDC_MISSING = "skiss render needs the Mermaid CLI: npm install -g @mermaid-js/mermaid-cli";
|
|
1843
|
+
var isFormat = (text) => text === "svg" || text === "png";
|
|
1844
|
+
async function render(mermaid, values) {
|
|
1845
|
+
const format = renderFormat(values);
|
|
1846
|
+
const scale = renderScale(values.scale, format);
|
|
1847
|
+
const mmdc = await findMmdc();
|
|
1848
|
+
if (mmdc === void 0) {
|
|
1849
|
+
process.stderr.write(`${MMDC_MISSING}
|
|
1850
|
+
`);
|
|
1851
|
+
return EXIT_USAGE;
|
|
1852
|
+
}
|
|
1853
|
+
const dir = await mkdtemp(join(tmpdir(), "skiss-render-"));
|
|
1854
|
+
try {
|
|
1855
|
+
const input = join(dir, "diagram.mmd");
|
|
1856
|
+
await writeFile(input, mermaid, "utf8");
|
|
1857
|
+
const target = values.output ?? join(dir, `diagram.${format}`);
|
|
1858
|
+
const args = ["-i", input, "-o", target, "-e", format, "-b", "transparent"];
|
|
1859
|
+
if (scale !== void 0) args.push("--scale", scale);
|
|
1860
|
+
const run = spawnSync(mmdc, args, {
|
|
1861
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
1862
|
+
encoding: "utf8"
|
|
1863
|
+
});
|
|
1864
|
+
if (run.error !== void 0)
|
|
1865
|
+
throw new UsageError(`cannot run \`${mmdc}\`: ${reason(run.error)}`);
|
|
1866
|
+
if (run.stdout !== "") process.stderr.write(run.stdout);
|
|
1867
|
+
if (run.status !== EXIT_OK) return run.status ?? EXIT_INTERNAL;
|
|
1868
|
+
if (values.output === void 0) process.stdout.write(await readFile(target, "utf8"));
|
|
1869
|
+
return EXIT_OK;
|
|
1870
|
+
} finally {
|
|
1871
|
+
await rm(dir, { recursive: true, force: true });
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
function renderFormat(values) {
|
|
1875
|
+
if (values.format !== void 0) {
|
|
1876
|
+
if (!isFormat(values.format)) {
|
|
1877
|
+
throw new UsageError(`unknown \`--format ${values.format}\`: svg or png`);
|
|
1878
|
+
}
|
|
1879
|
+
if (values.format === "png" && values.output === void 0) {
|
|
1880
|
+
throw new UsageError(
|
|
1881
|
+
"render --format png needs -o <path>: a PNG cannot go to standard output"
|
|
1882
|
+
);
|
|
1883
|
+
}
|
|
1884
|
+
return values.format;
|
|
1885
|
+
}
|
|
1886
|
+
if (values.output === void 0) return "svg";
|
|
1887
|
+
const extension = extname(values.output).slice(1).toLowerCase();
|
|
1888
|
+
if (isFormat(extension)) return extension;
|
|
1889
|
+
throw new UsageError(
|
|
1890
|
+
`cannot tell the format of \`${values.output}\`: give --format svg or --format png`
|
|
1891
|
+
);
|
|
1892
|
+
}
|
|
1893
|
+
function renderScale(scale, format) {
|
|
1894
|
+
if (scale === void 0) return format === "png" ? "2" : void 0;
|
|
1895
|
+
const value = Number(scale);
|
|
1896
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
1897
|
+
throw new UsageError(`\`--scale ${scale}\` is not a positive number`);
|
|
1898
|
+
}
|
|
1899
|
+
return scale;
|
|
1900
|
+
}
|
|
1901
|
+
async function findMmdc() {
|
|
1902
|
+
const onPath = (process.env.PATH ?? "").split(delimiter).filter((dir) => dir !== "").map((dir) => join(dir, "mmdc"));
|
|
1903
|
+
for (const candidate of [...onPath, join(process.cwd(), "node_modules", ".bin", "mmdc")]) {
|
|
1904
|
+
try {
|
|
1905
|
+
await access(candidate, constants.X_OK);
|
|
1906
|
+
return candidate;
|
|
1907
|
+
} catch {
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
return void 0;
|
|
1911
|
+
}
|
|
1628
1912
|
function reason(error) {
|
|
1629
1913
|
if (error instanceof Error) {
|
|
1630
1914
|
const code = error.code;
|
package/dist/index.d.ts
CHANGED
|
@@ -55,6 +55,12 @@ interface FieldNode {
|
|
|
55
55
|
}
|
|
56
56
|
interface ClassNode {
|
|
57
57
|
name: Name;
|
|
58
|
+
/**
|
|
59
|
+
* The class after `<` (SPEC §3.10), set by `parse` whenever one is written.
|
|
60
|
+
* `resolve` clears it on the class whose `<` closes a circle, as it clears
|
|
61
|
+
* `identifier` on a second `*`, so no generator has to know about circles.
|
|
62
|
+
*/
|
|
63
|
+
parent?: Name;
|
|
58
64
|
system?: Name;
|
|
59
65
|
similarTo?: Name;
|
|
60
66
|
description?: string;
|
|
@@ -65,10 +71,11 @@ interface ClassNode {
|
|
|
65
71
|
type Severity = 'error' | 'warning';
|
|
66
72
|
/**
|
|
67
73
|
* Codes from the tables in docs/ARCHITECTURE.md. `parse` adds the `E_` codes,
|
|
68
|
-
* `resolve` the `W_` codes
|
|
74
|
+
* `resolve` the `W_` codes and `E_INHERITANCE_CYCLE`, the one error no single
|
|
75
|
+
* line can see (SPEC §7), and `importLinkML` the one code of its own,
|
|
69
76
|
* `E_NOT_YAML`, for a text no schema can be read out of.
|
|
70
77
|
*/
|
|
71
|
-
type DiagnosticCode = 'E_UNPARSABLE' | 'E_FIELD_WITHOUT_CLASS' | 'E_MISSING_TYPE' | 'E_UNCLOSED_MANY' | 'E_BAD_NAME' | 'W_UNKNOWN_TYPE' | 'W_UNDECLARED_CLASS' | 'W_UNDECLARED_FIELD' | 'W_DUPLICATE_CLASS' | 'W_DUPLICATE_FIELD' | 'W_DUPLICATE_ENUM_VALUE' | 'W_MULTIPLE_IDENTIFIERS' | 'E_NOT_YAML';
|
|
78
|
+
type DiagnosticCode = 'E_UNPARSABLE' | 'E_FIELD_WITHOUT_CLASS' | 'E_MISSING_TYPE' | 'E_UNCLOSED_MANY' | 'E_BAD_NAME' | 'E_INHERITANCE_CYCLE' | 'W_UNKNOWN_TYPE' | 'W_UNDECLARED_CLASS' | 'W_UNDECLARED_FIELD' | 'W_DUPLICATE_CLASS' | 'W_DUPLICATE_FIELD' | 'W_DUPLICATE_ENUM_VALUE' | 'W_MULTIPLE_IDENTIFIERS' | 'W_REDUNDANT_OVERRIDE' | 'E_NOT_YAML';
|
|
72
79
|
interface Diagnostic {
|
|
73
80
|
severity: Severity;
|
|
74
81
|
code: DiagnosticCode;
|
|
@@ -106,9 +113,10 @@ interface LinkMLAttribute {
|
|
|
106
113
|
identifier?: boolean;
|
|
107
114
|
annotations?: LinkMLAnnotations;
|
|
108
115
|
}
|
|
109
|
-
/** One entry under `classes`. Key order is SPEC §5.
|
|
116
|
+
/** One entry under `classes`. Key order is SPEC §5.1, Inheritance. */
|
|
110
117
|
interface LinkMLClass {
|
|
111
118
|
description?: string;
|
|
119
|
+
is_a?: string;
|
|
112
120
|
annotations?: LinkMLAnnotations;
|
|
113
121
|
close_mappings?: string[];
|
|
114
122
|
attributes?: Record<string, LinkMLAttribute>;
|
|
@@ -215,6 +223,6 @@ interface ResolvedDocument extends Document {
|
|
|
215
223
|
}
|
|
216
224
|
declare function resolve(doc: Document): ResolvedDocument;
|
|
217
225
|
|
|
218
|
-
declare const VERSION = "0.
|
|
226
|
+
declare const VERSION = "0.5.0";
|
|
219
227
|
|
|
220
228
|
export { type ClassNode, type CompileOptions, type CompileResult, type Diagnostic, type DiagnosticCode, type Document, type Dropped, type FieldNode, type FromLinkMLResult, type ImportResult, type JoinRef, type LinkMLAnnotations, type LinkMLAttribute, type LinkMLClass, type LinkMLCompileOptions, type LinkMLEnum, type LinkMLOptions, type LinkMLSchema, type MermaidCompileOptions, type MermaidOptions, type Name, type Primitive, type ResolvedDocument, type SerializeFormat, type Severity, type TypeRef, VERSION, compile, formatDiagnostic, formatDropped, fromLinkML, importLinkML, parse, resolve, serialize, toLinkML, toMermaid, toSkiss };
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,82 @@
|
|
|
1
|
+
// src/inheritance.ts
|
|
2
|
+
function inheritance(classes) {
|
|
3
|
+
const declared = /* @__PURE__ */ new Map();
|
|
4
|
+
for (const cls of classes) {
|
|
5
|
+
if (!declared.has(cls.name.text)) declared.set(cls.name.text, cls);
|
|
6
|
+
}
|
|
7
|
+
const circles = /* @__PURE__ */ new Map();
|
|
8
|
+
const parentOf = (cls) => {
|
|
9
|
+
if (cls.parent === void 0 || circles.has(cls)) return void 0;
|
|
10
|
+
return declared.get(cls.parent.text);
|
|
11
|
+
};
|
|
12
|
+
for (const cls of declared.values()) {
|
|
13
|
+
const path = [cls];
|
|
14
|
+
const seen = new Set(path);
|
|
15
|
+
for (let next = parentOf(cls); next !== void 0; next = parentOf(next)) {
|
|
16
|
+
if (seen.has(next)) {
|
|
17
|
+
const circle = path.slice(path.indexOf(next));
|
|
18
|
+
let closer = circle[0];
|
|
19
|
+
for (const member2 of circle) {
|
|
20
|
+
if (member2.line > closer.line) closer = member2;
|
|
21
|
+
}
|
|
22
|
+
const at = circle.indexOf(closer);
|
|
23
|
+
circles.set(closer, [...circle.slice(at), ...circle.slice(0, at)]);
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
path.push(next);
|
|
27
|
+
seen.add(next);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const ancestors = (cls) => {
|
|
31
|
+
const out = [];
|
|
32
|
+
const seen = /* @__PURE__ */ new Set([cls]);
|
|
33
|
+
for (let next = parentOf(cls); next !== void 0; next = parentOf(next)) {
|
|
34
|
+
if (seen.has(next)) break;
|
|
35
|
+
out.push(next);
|
|
36
|
+
seen.add(next);
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
};
|
|
40
|
+
const find = (cls, matches) => {
|
|
41
|
+
for (const from of ancestors(cls)) {
|
|
42
|
+
const field = from.fields.find(matches);
|
|
43
|
+
if (field !== void 0) return { field, from };
|
|
44
|
+
}
|
|
45
|
+
return void 0;
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
ancestors,
|
|
49
|
+
inherited: (cls, fieldName) => find(cls, (field) => field.name.text === fieldName),
|
|
50
|
+
inheritedIdentifier: (cls) => find(cls, (field) => field.identifier),
|
|
51
|
+
circle: (cls) => circles.get(cls)
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function sameField(child, inheritedField) {
|
|
55
|
+
return meaning(child) === meaning(inheritedField);
|
|
56
|
+
}
|
|
57
|
+
var meaning = (field) => JSON.stringify([
|
|
58
|
+
field.name.text,
|
|
59
|
+
field.identifier,
|
|
60
|
+
typeMeaning(field),
|
|
61
|
+
field.system?.text,
|
|
62
|
+
field.joinsTo === void 0 ? void 0 : [field.joinsTo.className.text, field.joinsTo.fieldName.text],
|
|
63
|
+
field.description,
|
|
64
|
+
field.note
|
|
65
|
+
]);
|
|
66
|
+
function typeMeaning(field) {
|
|
67
|
+
const type = field.type;
|
|
68
|
+
if (type === void 0) return void 0;
|
|
69
|
+
switch (type.kind) {
|
|
70
|
+
case "primitive":
|
|
71
|
+
return [type.kind, type.name, type.many];
|
|
72
|
+
case "class":
|
|
73
|
+
case "unknown":
|
|
74
|
+
return [type.kind, type.name.text, type.many];
|
|
75
|
+
case "enum":
|
|
76
|
+
return [type.kind, type.values.map((v) => v.text), type.many];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
1
80
|
// src/parse.ts
|
|
2
81
|
var isWs = (c) => c === " " || c === " ";
|
|
3
82
|
var trimWs = (s) => s.replace(/^[ \t]+/, "").replace(/[ \t]+$/, "");
|
|
@@ -21,7 +100,7 @@ function primitiveFor(word) {
|
|
|
21
100
|
return Object.hasOwn(PRIMITIVES, word) ? PRIMITIVES[word] : void 0;
|
|
22
101
|
}
|
|
23
102
|
var FIELD_ORDER = "name, `*`, `: type`, `@System`, `= Class.field`, then `#` and `?`";
|
|
24
|
-
var CLASS_ORDER = "name, `@System`, `~ Class`, then `#` and `?`";
|
|
103
|
+
var CLASS_ORDER = "name, `< Parent`, `@System`, `~ Class`, then `#` and `?`";
|
|
25
104
|
function parse(source) {
|
|
26
105
|
const classes = [];
|
|
27
106
|
const diagnostics = [];
|
|
@@ -108,7 +187,7 @@ function splitTrailer(text) {
|
|
|
108
187
|
return trailer;
|
|
109
188
|
}
|
|
110
189
|
var WORD = /[\p{L}\p{N}_-]+/uy;
|
|
111
|
-
var OPERATORS = ["*", ":", "@", "~", "=", ".", "|", "["];
|
|
190
|
+
var OPERATORS = ["*", ":", "@", "~", "=", ".", "|", "<", ",", "["];
|
|
112
191
|
function tokenize(head) {
|
|
113
192
|
const tokens = [];
|
|
114
193
|
let i = 0;
|
|
@@ -170,6 +249,13 @@ function parseClassLine(text, line) {
|
|
|
170
249
|
);
|
|
171
250
|
}
|
|
172
251
|
const node = { name: name(head, line), line };
|
|
252
|
+
const lt = peek(c);
|
|
253
|
+
if (lt?.kind === "<") {
|
|
254
|
+
take(c);
|
|
255
|
+
const parent = parseParent(c, lt);
|
|
256
|
+
if (!parent.ok) return parent;
|
|
257
|
+
node.parent = parent.value;
|
|
258
|
+
}
|
|
173
259
|
const at = peek(c);
|
|
174
260
|
if (at?.kind === "@") {
|
|
175
261
|
take(c);
|
|
@@ -299,6 +385,31 @@ function parseFieldLine(text, line) {
|
|
|
299
385
|
if (trailer.note !== void 0) node.note = trailer.note;
|
|
300
386
|
return { ok: true, value: node };
|
|
301
387
|
}
|
|
388
|
+
function parseParent(c, lt) {
|
|
389
|
+
const parent = take(c);
|
|
390
|
+
if (parent === void 0 || parent.kind !== "word") {
|
|
391
|
+
return fail(
|
|
392
|
+
"E_UNPARSABLE",
|
|
393
|
+
"Expected a class name after `<`, e.g. `Jedi < Character`",
|
|
394
|
+
c.line,
|
|
395
|
+
parent ?? afterSpan(lt)
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
if (!CLASS_NAME.test(parent.text)) {
|
|
399
|
+
const message = Object.hasOwn(PRIMITIVES, parent.text) ? `\`${parent.text}\` is a primitive, not a class; a parent is a class` : `The class after \`<\` must be UpperCamelCase: \`${parent.text}\``;
|
|
400
|
+
return fail("E_BAD_NAME", message, c.line, parent);
|
|
401
|
+
}
|
|
402
|
+
const next = peek(c);
|
|
403
|
+
if (next?.kind === "," || next?.kind === "<") {
|
|
404
|
+
return fail(
|
|
405
|
+
"E_UNPARSABLE",
|
|
406
|
+
"A class has one parent; `<` takes a single class name",
|
|
407
|
+
c.line,
|
|
408
|
+
next
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
return { ok: true, value: name(parent, c.line) };
|
|
412
|
+
}
|
|
302
413
|
function parseSystem(c, at) {
|
|
303
414
|
const system = take(c);
|
|
304
415
|
if (system === void 0 || system.kind !== "word") {
|
|
@@ -408,7 +519,7 @@ function parseType(c, colon) {
|
|
|
408
519
|
);
|
|
409
520
|
}
|
|
410
521
|
function unexpected(token, line, kind, order) {
|
|
411
|
-
const modifiers = kind === "field" ? ["*", ":", "@", "="] : ["@", "~"];
|
|
522
|
+
const modifiers = kind === "field" ? ["*", ":", "@", "="] : ["<", "@", "~"];
|
|
412
523
|
if (modifiers.includes(token.kind)) {
|
|
413
524
|
return fail(
|
|
414
525
|
"E_UNPARSABLE",
|
|
@@ -417,10 +528,10 @@ function unexpected(token, line, kind, order) {
|
|
|
417
528
|
token
|
|
418
529
|
);
|
|
419
530
|
}
|
|
420
|
-
if (token.
|
|
531
|
+
if (token.kind === "<") {
|
|
421
532
|
return fail(
|
|
422
533
|
"E_UNPARSABLE",
|
|
423
|
-
"`<`
|
|
534
|
+
"`<` marks inheritance and belongs on the class line: `Child < Parent`",
|
|
424
535
|
line,
|
|
425
536
|
token
|
|
426
537
|
);
|
|
@@ -449,10 +560,14 @@ function resolve(doc) {
|
|
|
449
560
|
};
|
|
450
561
|
const classes = out.classes;
|
|
451
562
|
const declared = new Set(classes.map((c) => c.name.text));
|
|
452
|
-
const
|
|
563
|
+
const found = [];
|
|
453
564
|
const undeclared = /* @__PURE__ */ new Map();
|
|
565
|
+
const tree = inheritance(classes);
|
|
454
566
|
const warn = (code, message, at) => {
|
|
455
|
-
|
|
567
|
+
found.push({ severity: "warning", code, message, line: at.line, col: at.col, end: at.end });
|
|
568
|
+
};
|
|
569
|
+
const error = (code, message, at) => {
|
|
570
|
+
found.push({ severity: "error", code, message, line: at.line, col: at.col, end: at.end });
|
|
456
571
|
};
|
|
457
572
|
const reference = (name2) => {
|
|
458
573
|
if (declared.has(name2.text)) return true;
|
|
@@ -473,9 +588,25 @@ function resolve(doc) {
|
|
|
473
588
|
cls.name
|
|
474
589
|
);
|
|
475
590
|
}
|
|
591
|
+
const parent = cls.parent;
|
|
592
|
+
if (parent !== void 0) {
|
|
593
|
+
reference(parent);
|
|
594
|
+
const circle = tree.circle(cls);
|
|
595
|
+
if (circle !== void 0) {
|
|
596
|
+
const through = circle.slice(1).map((member2) => `\`${member2.name.text}\``);
|
|
597
|
+
const path = through.length === 0 ? "" : ` through ${through.join(", ")}`;
|
|
598
|
+
error(
|
|
599
|
+
"E_INHERITANCE_CYCLE",
|
|
600
|
+
`class \`${cls.name.text}\` inherits from itself${path}; this \`<\` is not carried`,
|
|
601
|
+
parent
|
|
602
|
+
);
|
|
603
|
+
cls.parent = void 0;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
476
606
|
if (cls.similarTo !== void 0) reference(cls.similarTo);
|
|
477
607
|
const firstField = /* @__PURE__ */ new Map();
|
|
478
|
-
|
|
608
|
+
const inheritedIdentifier = tree.inheritedIdentifier(cls);
|
|
609
|
+
let identifier = inheritedIdentifier?.field;
|
|
479
610
|
for (const field of cls.fields) {
|
|
480
611
|
const earlierField = firstField.get(field.name.text);
|
|
481
612
|
if (earlierField === void 0) firstField.set(field.name.text, field);
|
|
@@ -489,14 +620,23 @@ function resolve(doc) {
|
|
|
489
620
|
if (field.identifier) {
|
|
490
621
|
if (identifier === void 0) identifier = field;
|
|
491
622
|
else {
|
|
623
|
+
const from = identifier === inheritedIdentifier?.field ? ` inherits the identifier \`${identifier.name.text}\` from class \`${inheritedIdentifier.from.name.text}\`, line` : ` already has the identifier \`${identifier.name.text}\` on line`;
|
|
492
624
|
warn(
|
|
493
625
|
"W_MULTIPLE_IDENTIFIERS",
|
|
494
|
-
`\`*\` on \`${field.name.text}\` is ignored: class \`${cls.name.text}
|
|
626
|
+
`\`*\` on \`${field.name.text}\` is ignored: class \`${cls.name.text}\`${from} ${identifier.line}`,
|
|
495
627
|
field.identifierAt ?? field.name
|
|
496
628
|
);
|
|
497
629
|
field.identifier = false;
|
|
498
630
|
}
|
|
499
631
|
}
|
|
632
|
+
const replaced = tree.inherited(cls, field.name.text);
|
|
633
|
+
if (replaced !== void 0 && sameField(field, replaced.field)) {
|
|
634
|
+
warn(
|
|
635
|
+
"W_REDUNDANT_OVERRIDE",
|
|
636
|
+
`field \`${field.name.text}\` is identical to the one inherited from class \`${replaced.from.name.text}\` on line ${replaced.field.line}`,
|
|
637
|
+
field.name
|
|
638
|
+
);
|
|
639
|
+
}
|
|
500
640
|
if (field.type?.kind === "unknown") {
|
|
501
641
|
const word = field.type.name.text;
|
|
502
642
|
const suggestion = closestTypeWord(word);
|
|
@@ -534,8 +674,8 @@ function resolve(doc) {
|
|
|
534
674
|
}
|
|
535
675
|
}
|
|
536
676
|
const present2 = new Set(out.diagnostics.map(key));
|
|
537
|
-
for (const
|
|
538
|
-
if (!present2.has(key(
|
|
677
|
+
for (const d of found) {
|
|
678
|
+
if (!present2.has(key(d))) out.diagnostics.push(d);
|
|
539
679
|
}
|
|
540
680
|
out.diagnostics.sort((a, b) => a.line - b.line);
|
|
541
681
|
out.undeclared = [...undeclared.values()].map((n) => ({ ...n }));
|
|
@@ -566,6 +706,7 @@ function copyField(f) {
|
|
|
566
706
|
}
|
|
567
707
|
function copyClass(c) {
|
|
568
708
|
const out = { ...c, name: copyName(c.name), fields: c.fields.map(copyField) };
|
|
709
|
+
if (c.parent !== void 0) out.parent = copyName(c.parent);
|
|
569
710
|
if (c.system !== void 0) out.system = copyName(c.system);
|
|
570
711
|
if (c.similarTo !== void 0) out.similarTo = copyName(c.similarTo);
|
|
571
712
|
return out;
|
|
@@ -744,6 +885,7 @@ function buildClass(cls, mappingPrefix, enums) {
|
|
|
744
885
|
const { node, fields } = cls;
|
|
745
886
|
const out = {};
|
|
746
887
|
if (node.description !== void 0) out.description = node.description;
|
|
888
|
+
if (node.parent !== void 0) out.is_a = node.parent.text;
|
|
747
889
|
const annotations = {};
|
|
748
890
|
if (node.system !== void 0) annotations.system = node.system.text;
|
|
749
891
|
if (node.note !== void 0) annotations.note = node.note;
|
|
@@ -826,6 +968,7 @@ function toMermaid(doc, opts) {
|
|
|
826
968
|
}
|
|
827
969
|
for (const cls of resolved.classes) {
|
|
828
970
|
const from = cls.name.text;
|
|
971
|
+
if (cls.parent !== void 0) lines.push(` ${cls.parent.text} <|-- ${from}`);
|
|
829
972
|
if (cls.similarTo !== void 0) lines.push(` ${from} ..> ${cls.similarTo.text} : similar`);
|
|
830
973
|
for (const field of cls.fields) {
|
|
831
974
|
const type = field.type;
|
|
@@ -932,7 +1075,7 @@ function toSkiss(doc) {
|
|
|
932
1075
|
`;
|
|
933
1076
|
}
|
|
934
1077
|
function classBlock(cls) {
|
|
935
|
-
const head = cls.name.text + (cls.system === void 0 ? "" : ` @${cls.system.text}`) + (cls.similarTo === void 0 ? "" : ` ~ ${cls.similarTo.text}`);
|
|
1078
|
+
const head = cls.name.text + (cls.parent === void 0 ? "" : ` < ${cls.parent.text}`) + (cls.system === void 0 ? "" : ` @${cls.system.text}`) + (cls.similarTo === void 0 ? "" : ` ~ ${cls.similarTo.text}`);
|
|
936
1079
|
return [withTrailer(head, cls), ...cls.fields.map(fieldLine)].join("\n");
|
|
937
1080
|
}
|
|
938
1081
|
function fieldLine(field) {
|
|
@@ -986,7 +1129,14 @@ var PRIMITIVE_RANGE = {
|
|
|
986
1129
|
uri: "uri",
|
|
987
1130
|
uriorcurie: "uri"
|
|
988
1131
|
};
|
|
989
|
-
var CLASS_KEYS = /* @__PURE__ */ new Set([
|
|
1132
|
+
var CLASS_KEYS = /* @__PURE__ */ new Set([
|
|
1133
|
+
"description",
|
|
1134
|
+
"is_a",
|
|
1135
|
+
"annotations",
|
|
1136
|
+
"close_mappings",
|
|
1137
|
+
"attributes",
|
|
1138
|
+
"slots"
|
|
1139
|
+
]);
|
|
990
1140
|
var SLOT_KEYS = /* @__PURE__ */ new Set(["description", "annotations", "range", "multivalued", "identifier"]);
|
|
991
1141
|
var CLASS_TAGS = /* @__PURE__ */ new Set(["system", "note", "undeclared"]);
|
|
992
1142
|
var SLOT_TAGS = /* @__PURE__ */ new Set(["system", "note", "joins_to"]);
|
|
@@ -1029,7 +1179,7 @@ function fromLinkML(schema) {
|
|
|
1029
1179
|
for (const key2 of Object.keys(classDefs)) {
|
|
1030
1180
|
const names = /* @__PURE__ */ new Map();
|
|
1031
1181
|
const taken = /* @__PURE__ */ new Set();
|
|
1032
|
-
for (const slot of
|
|
1182
|
+
for (const slot of fieldKeysOf(key2)) {
|
|
1033
1183
|
names.set(slot, unique(toFieldName(slot), taken));
|
|
1034
1184
|
}
|
|
1035
1185
|
fieldNames.set(key2, names);
|
|
@@ -1099,6 +1249,12 @@ function fromLinkML(schema) {
|
|
|
1099
1249
|
const note = oneLine(rawNote);
|
|
1100
1250
|
if (note !== void 0) node.note = note;
|
|
1101
1251
|
} else if (rawNote !== void 0) report("annotation", name2, notText("note", rawNote));
|
|
1252
|
+
const parent = stringOf(get(def, "is_a"));
|
|
1253
|
+
if (parent !== void 0) {
|
|
1254
|
+
const parentName = Object.hasOwn(classDefs, parent) ? classNameOf(parent) : toClassName(parent);
|
|
1255
|
+
node.parent = at0(parentName);
|
|
1256
|
+
reference(name2, parent, parentName);
|
|
1257
|
+
}
|
|
1102
1258
|
const mappings = stringList(get(def, "close_mappings"));
|
|
1103
1259
|
const first = mappings[0];
|
|
1104
1260
|
const target = first === void 0 ? "" : localName(first);
|
|
@@ -1121,6 +1277,9 @@ function fromLinkML(schema) {
|
|
|
1121
1277
|
}
|
|
1122
1278
|
}
|
|
1123
1279
|
const block = get(def, other);
|
|
1280
|
+
if (other === "is_a" && present(block) && stringOf(block) === void 0) {
|
|
1281
|
+
report("is_a", name2, `${shown(block)} is not a class name`);
|
|
1282
|
+
}
|
|
1124
1283
|
if (other === "attributes" && present(block) && asRecord(block) === void 0) {
|
|
1125
1284
|
report("attributes", name2, `${shown(block)} is not a map`);
|
|
1126
1285
|
}
|
|
@@ -1135,10 +1294,12 @@ function fromLinkML(schema) {
|
|
|
1135
1294
|
report("slot_usage", name2);
|
|
1136
1295
|
continue;
|
|
1137
1296
|
}
|
|
1297
|
+
const written = slotKeysOf(def);
|
|
1298
|
+
const inherited = inheritedSlots(key2);
|
|
1138
1299
|
for (const slot of Object.keys(usage)) {
|
|
1139
|
-
if (
|
|
1300
|
+
if (written.includes(slot)) {
|
|
1140
1301
|
report("slot_usage", `${name2}.${fieldNameOf(key2, slot)}`);
|
|
1141
|
-
} else {
|
|
1302
|
+
} else if (!inherited.has(slot)) {
|
|
1142
1303
|
report("slot_usage", name2, `\`${slot}\` is inherited and not in the sketch`);
|
|
1143
1304
|
}
|
|
1144
1305
|
}
|
|
@@ -1146,9 +1307,10 @@ function fromLinkML(schema) {
|
|
|
1146
1307
|
}
|
|
1147
1308
|
report(other, name2);
|
|
1148
1309
|
}
|
|
1149
|
-
|
|
1310
|
+
const own = slotKeysOf(def);
|
|
1311
|
+
for (const slot of fieldKeysOf(key2)) {
|
|
1150
1312
|
const attributes = asRecord(get(def, "attributes")) ?? {};
|
|
1151
|
-
const rawSlot = Object.hasOwn(attributes, slot) ? get(attributes, slot) : get(slotDefs, slot);
|
|
1313
|
+
const rawSlot = !own.includes(slot) ? get(asRecord(get(def, "slot_usage")) ?? {}, slot) : Object.hasOwn(attributes, slot) ? get(attributes, slot) : get(slotDefs, slot);
|
|
1152
1314
|
const slotDef = asRecord(rawSlot);
|
|
1153
1315
|
if (slotDef === void 0 && present(rawSlot)) {
|
|
1154
1316
|
report("slot", `${name2}.${fieldNameOf(key2, slot)}`, "is not a slot definition");
|
|
@@ -1285,6 +1447,34 @@ function fromLinkML(schema) {
|
|
|
1285
1447
|
report("narrowed", element, `\`${range}\` is not a Skiss type; it falls back to string`);
|
|
1286
1448
|
return { kind: "unknown", name: at0(word), many };
|
|
1287
1449
|
}
|
|
1450
|
+
function parentKeyOf(classKey) {
|
|
1451
|
+
const def = asRecord(get(classDefs, classKey)) ?? {};
|
|
1452
|
+
const parent = stringOf(get(def, "is_a"));
|
|
1453
|
+
if (parent === void 0 || !Object.hasOwn(classDefs, parent)) return void 0;
|
|
1454
|
+
return parent;
|
|
1455
|
+
}
|
|
1456
|
+
function inheritedSlots(classKey) {
|
|
1457
|
+
const out = /* @__PURE__ */ new Set();
|
|
1458
|
+
const seen = /* @__PURE__ */ new Set([classKey]);
|
|
1459
|
+
let key2 = parentKeyOf(classKey);
|
|
1460
|
+
while (key2 !== void 0 && !seen.has(key2)) {
|
|
1461
|
+
seen.add(key2);
|
|
1462
|
+
for (const slot of slotKeysOf(asRecord(get(classDefs, key2)) ?? {})) out.add(slot);
|
|
1463
|
+
key2 = parentKeyOf(key2);
|
|
1464
|
+
}
|
|
1465
|
+
return out;
|
|
1466
|
+
}
|
|
1467
|
+
function fieldKeysOf(classKey) {
|
|
1468
|
+
const def = asRecord(get(classDefs, classKey)) ?? {};
|
|
1469
|
+
const keys = slotKeysOf(def);
|
|
1470
|
+
const usage = asRecord(get(def, "slot_usage"));
|
|
1471
|
+
if (usage === void 0) return keys;
|
|
1472
|
+
const inherited = inheritedSlots(classKey);
|
|
1473
|
+
for (const slot of Object.keys(usage)) {
|
|
1474
|
+
if (inherited.has(slot) && !keys.includes(slot)) keys.push(slot);
|
|
1475
|
+
}
|
|
1476
|
+
return keys;
|
|
1477
|
+
}
|
|
1288
1478
|
function listedSomewhere(slot) {
|
|
1289
1479
|
for (const key2 of Object.keys(classDefs)) {
|
|
1290
1480
|
const def = asRecord(get(classDefs, key2)) ?? {};
|
|
@@ -1467,7 +1657,7 @@ function notYAML(error) {
|
|
|
1467
1657
|
}
|
|
1468
1658
|
|
|
1469
1659
|
// src/index.ts
|
|
1470
|
-
var VERSION = "0.
|
|
1660
|
+
var VERSION = "0.5.0";
|
|
1471
1661
|
export {
|
|
1472
1662
|
VERSION,
|
|
1473
1663
|
compile,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eriknaslund/skiss",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "A text notation for sketching data models, compiled to LinkML",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,7 +41,8 @@
|
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@biomejs/biome": "^2.5.13",
|
|
44
|
-
"@
|
|
44
|
+
"@mermaid-js/mermaid-cli": "^11.17.0",
|
|
45
|
+
"@types/node": "^26.6.1",
|
|
45
46
|
"jsdom": "^30.0.1",
|
|
46
47
|
"mermaid": "^12.0.0",
|
|
47
48
|
"tsup": "^8.5.1",
|