@eriknaslund/skiss 0.3.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/dist/cli.js CHANGED
@@ -1,16 +1,99 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { readFile, writeFile } from "fs/promises";
5
- import { basename, extname } from "path";
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]+$/, "");
11
93
  var CLASS_NAME = /^[A-Z][A-Za-z0-9]*$/;
12
94
  var FIELD_NAME = /^[a-z][A-Za-z0-9]*$/;
13
- var SYSTEM_OR_VALUE = /^[A-Za-z][A-Za-z0-9_-]*$/;
95
+ var SYSTEM_NAME = /^[A-Za-z][A-Za-z0-9_-]*$/;
96
+ var ENUM_VALUE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
14
97
  var PRIMITIVES = {
15
98
  string: "string",
16
99
  int: "int",
@@ -27,7 +110,7 @@ function primitiveFor(word) {
27
110
  return Object.hasOwn(PRIMITIVES, word) ? PRIMITIVES[word] : void 0;
28
111
  }
29
112
  var FIELD_ORDER = "name, `*`, `: type`, `@System`, `= Class.field`, then `#` and `?`";
30
- var CLASS_ORDER = "name, `@System`, `~ Class`, then `#` and `?`";
113
+ var CLASS_ORDER = "name, `< Parent`, `@System`, `~ Class`, then `#` and `?`";
31
114
  function parse(source) {
32
115
  const classes = [];
33
116
  const diagnostics = [];
@@ -114,7 +197,7 @@ function splitTrailer(text) {
114
197
  return trailer;
115
198
  }
116
199
  var WORD = /[\p{L}\p{N}_-]+/uy;
117
- var OPERATORS = ["*", ":", "@", "~", "=", ".", "|", "["];
200
+ var OPERATORS = ["*", ":", "@", "~", "=", ".", "|", "<", ",", "["];
118
201
  function tokenize(head) {
119
202
  const tokens = [];
120
203
  let i = 0;
@@ -176,6 +259,13 @@ function parseClassLine(text, line) {
176
259
  );
177
260
  }
178
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
+ }
179
269
  const at = peek(c);
180
270
  if (at?.kind === "@") {
181
271
  take(c);
@@ -305,6 +395,31 @@ function parseFieldLine(text, line) {
305
395
  if (trailer.note !== void 0) node.note = trailer.note;
306
396
  return { ok: true, value: node };
307
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
+ }
308
423
  function parseSystem(c, at) {
309
424
  const system = take(c);
310
425
  if (system === void 0 || system.kind !== "word") {
@@ -315,7 +430,7 @@ function parseSystem(c, at) {
315
430
  system ?? afterSpan(at)
316
431
  );
317
432
  }
318
- if (!SYSTEM_OR_VALUE.test(system.text)) {
433
+ if (!SYSTEM_NAME.test(system.text)) {
319
434
  return fail(
320
435
  "E_UNPARSABLE",
321
436
  `A system name is letters, digits, \`-\` and \`_\`, starting with a letter: \`${system.text}\``,
@@ -373,10 +488,10 @@ function parseType(c, colon) {
373
488
  }
374
489
  if (words.length > 1) {
375
490
  for (const w of words) {
376
- if (!SYSTEM_OR_VALUE.test(w.text)) {
491
+ if (!ENUM_VALUE.test(w.text)) {
377
492
  return fail(
378
493
  "E_UNPARSABLE",
379
- `An enum value is letters, digits, \`-\` and \`_\`, starting with a letter: \`${w.text}\``,
494
+ `An enum value is letters, digits, \`-\` and \`_\`, and does not start with \`-\` or \`_\`: \`${w.text}\``,
380
495
  c.line,
381
496
  w
382
497
  );
@@ -414,7 +529,7 @@ function parseType(c, colon) {
414
529
  );
415
530
  }
416
531
  function unexpected(token, line, kind, order) {
417
- const modifiers = kind === "field" ? ["*", ":", "@", "="] : ["@", "~"];
532
+ const modifiers = kind === "field" ? ["*", ":", "@", "="] : ["<", "@", "~"];
418
533
  if (modifiers.includes(token.kind)) {
419
534
  return fail(
420
535
  "E_UNPARSABLE",
@@ -423,10 +538,10 @@ function unexpected(token, line, kind, order) {
423
538
  token
424
539
  );
425
540
  }
426
- if (token.text === "<") {
541
+ if (token.kind === "<") {
427
542
  return fail(
428
543
  "E_UNPARSABLE",
429
- "`<` is reserved for inheritance and not yet part of the language",
544
+ "`<` marks inheritance and belongs on the class line: `Child < Parent`",
430
545
  line,
431
546
  token
432
547
  );
@@ -455,10 +570,14 @@ function resolve(doc) {
455
570
  };
456
571
  const classes = out.classes;
457
572
  const declared = new Set(classes.map((c) => c.name.text));
458
- const warnings = [];
573
+ const found = [];
459
574
  const undeclared = /* @__PURE__ */ new Map();
575
+ const tree = inheritance(classes);
460
576
  const warn = (code, message, at) => {
461
- warnings.push({ severity: "warning", code, message, line: at.line, col: at.col, end: at.end });
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 });
462
581
  };
463
582
  const reference = (name2) => {
464
583
  if (declared.has(name2.text)) return true;
@@ -479,9 +598,25 @@ function resolve(doc) {
479
598
  cls.name
480
599
  );
481
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
+ }
482
616
  if (cls.similarTo !== void 0) reference(cls.similarTo);
483
617
  const firstField = /* @__PURE__ */ new Map();
484
- let identifier;
618
+ const inheritedIdentifier = tree.inheritedIdentifier(cls);
619
+ let identifier = inheritedIdentifier?.field;
485
620
  for (const field of cls.fields) {
486
621
  const earlierField = firstField.get(field.name.text);
487
622
  if (earlierField === void 0) firstField.set(field.name.text, field);
@@ -495,14 +630,23 @@ function resolve(doc) {
495
630
  if (field.identifier) {
496
631
  if (identifier === void 0) identifier = field;
497
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`;
498
634
  warn(
499
635
  "W_MULTIPLE_IDENTIFIERS",
500
- `\`*\` on \`${field.name.text}\` is ignored: class \`${cls.name.text}\` already has the identifier \`${identifier.name.text}\` on line ${identifier.line}`,
636
+ `\`*\` on \`${field.name.text}\` is ignored: class \`${cls.name.text}\`${from} ${identifier.line}`,
501
637
  field.identifierAt ?? field.name
502
638
  );
503
639
  field.identifier = false;
504
640
  }
505
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
+ }
506
650
  if (field.type?.kind === "unknown") {
507
651
  const word = field.type.name.text;
508
652
  const suggestion = closestTypeWord(word);
@@ -513,6 +657,18 @@ function resolve(doc) {
513
657
  );
514
658
  } else if (field.type?.kind === "class") {
515
659
  reference(field.type.name);
660
+ } else if (field.type?.kind === "enum") {
661
+ const seen = /* @__PURE__ */ new Set();
662
+ for (const value of field.type.values) {
663
+ if (seen.has(value.text)) {
664
+ warn(
665
+ "W_DUPLICATE_ENUM_VALUE",
666
+ `value \`${value.text}\` is already in this enum; it is carried once`,
667
+ value
668
+ );
669
+ }
670
+ seen.add(value.text);
671
+ }
516
672
  }
517
673
  if (field.joinsTo !== void 0) {
518
674
  const { className, fieldName } = field.joinsTo;
@@ -528,8 +684,8 @@ function resolve(doc) {
528
684
  }
529
685
  }
530
686
  const present2 = new Set(out.diagnostics.map(key));
531
- for (const w of warnings) {
532
- if (!present2.has(key(w))) out.diagnostics.push(w);
687
+ for (const d of found) {
688
+ if (!present2.has(key(d))) out.diagnostics.push(d);
533
689
  }
534
690
  out.diagnostics.sort((a, b) => a.line - b.line);
535
691
  out.undeclared = [...undeclared.values()].map((n) => ({ ...n }));
@@ -560,6 +716,7 @@ function copyField(f) {
560
716
  }
561
717
  function copyClass(c) {
562
718
  const out = { ...c, name: copyName(c.name), fields: c.fields.map(copyField) };
719
+ if (c.parent !== void 0) out.parent = copyName(c.parent);
563
720
  if (c.system !== void 0) out.system = copyName(c.system);
564
721
  if (c.similarTo !== void 0) out.similarTo = copyName(c.similarTo);
565
722
  return out;
@@ -626,7 +783,8 @@ function toLinkML(doc, opts) {
626
783
  const classNames = new Set(classes.map((cls) => cls.node.name.text));
627
784
  for (const name2 of resolved.undeclared) classNames.add(name2.text);
628
785
  const enums = nameEnums(classes, classNames);
629
- const prefixes = { linkml: "https://w3id.org/linkml/" };
786
+ const prefixes = nameKeyed();
787
+ prefixes.linkml = "https://w3id.org/linkml/";
630
788
  if (!Object.hasOwn(prefixes, schemaName)) prefixes[schemaName] = `${BASE_URI}/${schemaName}/`;
631
789
  const systemPrefix = /* @__PURE__ */ new Map();
632
790
  const addSystem = (system) => {
@@ -657,12 +815,12 @@ function toLinkML(doc, opts) {
657
815
  prefixes,
658
816
  imports: ["linkml:types"]
659
817
  };
660
- const out = {};
818
+ const out = nameKeyed();
661
819
  for (const cls of classes) {
662
820
  out[cls.node.name.text] = buildClass(cls, mappingPrefix, enums);
663
821
  }
664
822
  for (const name2 of resolved.undeclared) {
665
- if (!(name2.text in out)) out[name2.text] = { annotations: { undeclared: true } };
823
+ if (!Object.hasOwn(out, name2.text)) out[name2.text] = { annotations: { undeclared: true } };
666
824
  }
667
825
  if (Object.keys(out).length > 0) schema.classes = out;
668
826
  const enumBlock = buildEnums(classes, enums);
@@ -720,13 +878,13 @@ function nameEnums(classes, classNames) {
720
878
  return names;
721
879
  }
722
880
  function buildEnums(classes, enums) {
723
- const out = {};
881
+ const out = nameKeyed();
724
882
  for (const { node, fields } of classes) {
725
883
  for (const field of fields) {
726
884
  if (field.type?.kind !== "enum") continue;
727
885
  const name2 = enums.get(useKey(node.name.text, field.name.text));
728
- if (name2 === void 0 || name2 in out) continue;
729
- const values = {};
886
+ if (name2 === void 0 || Object.hasOwn(out, name2)) continue;
887
+ const values = nameKeyed();
730
888
  for (const value of field.type.values) values[value.text] = null;
731
889
  out[name2] = { permissible_values: values };
732
890
  }
@@ -737,6 +895,7 @@ function buildClass(cls, mappingPrefix, enums) {
737
895
  const { node, fields } = cls;
738
896
  const out = {};
739
897
  if (node.description !== void 0) out.description = node.description;
898
+ if (node.parent !== void 0) out.is_a = node.parent.text;
740
899
  const annotations = {};
741
900
  if (node.system !== void 0) annotations.system = node.system.text;
742
901
  if (node.note !== void 0) annotations.note = node.note;
@@ -745,7 +904,7 @@ function buildClass(cls, mappingPrefix, enums) {
745
904
  const target = node.similarTo.text;
746
905
  out.close_mappings = [`${mappingPrefix(target)}:${target}`];
747
906
  }
748
- const attributes = {};
907
+ const attributes = nameKeyed();
749
908
  for (const field of fields) {
750
909
  attributes[field.name.text] = buildAttribute(field, node.name.text, enums);
751
910
  }
@@ -783,10 +942,12 @@ function rangeOf(field, className, enums) {
783
942
  }
784
943
  }
785
944
  var RESERVED_PREFIXES = /* @__PURE__ */ new Set(["linkml", "xsd", "shex", "schema"]);
945
+ var PROTOTYPE_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
946
+ var nameKeyed = () => /* @__PURE__ */ Object.create(null);
786
947
  var linkmlName = (text) => {
787
948
  const name2 = text.toLowerCase().replace(/[^a-z0-9]/g, "_");
788
949
  if (!/[a-z0-9]/.test(name2)) return "sketch";
789
- if (RESERVED_PREFIXES.has(name2)) return `${name2}_`;
950
+ if (RESERVED_PREFIXES.has(name2) || PROTOTYPE_KEYS.has(name2)) return `${name2}_`;
790
951
  return /^[0-9]/.test(name2) ? `_${name2}` : name2;
791
952
  };
792
953
  var capitalise = (text) => text.charAt(0).toUpperCase() + text.slice(1);
@@ -817,6 +978,7 @@ function toMermaid(doc, opts) {
817
978
  }
818
979
  for (const cls of resolved.classes) {
819
980
  const from = cls.name.text;
981
+ if (cls.parent !== void 0) lines.push(` ${cls.parent.text} <|-- ${from}`);
820
982
  if (cls.similarTo !== void 0) lines.push(` ${from} ..> ${cls.similarTo.text} : similar`);
821
983
  for (const field of cls.fields) {
822
984
  const type = field.type;
@@ -923,7 +1085,7 @@ function toSkiss(doc) {
923
1085
  `;
924
1086
  }
925
1087
  function classBlock(cls) {
926
- 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}`);
927
1089
  return [withTrailer(head, cls), ...cls.fields.map(fieldLine)].join("\n");
928
1090
  }
929
1091
  function fieldLine(field) {
@@ -949,10 +1111,15 @@ function typeText2(type) {
949
1111
  }
950
1112
  return type.many ? `${text}[]` : text;
951
1113
  }
1114
+ function writeDescription(text) {
1115
+ const attached = text.replace(/[ \t]+\?(?=[ \t]|$)/g, "?");
1116
+ return /^\?([ \t]|$)/.test(attached) ? `#${attached}` : `# ${attached}`;
1117
+ }
1118
+ var rewordsDescription = (text) => writeDescription(text) !== `# ${text}`;
952
1119
  function withTrailer(head, node) {
953
1120
  const parts2 = [];
954
1121
  if (node.description !== void 0 && node.description !== "")
955
- parts2.push(`# ${node.description}`);
1122
+ parts2.push(writeDescription(node.description));
956
1123
  if (node.note !== void 0 && node.note !== "") parts2.push(`? ${node.note}`);
957
1124
  if (parts2.length === 0) return head;
958
1125
  const gap = head.length < TRAILER_COLUMN - 1 ? " ".repeat(TRAILER_COLUMN - head.length) : " ";
@@ -972,7 +1139,14 @@ var PRIMITIVE_RANGE = {
972
1139
  uri: "uri",
973
1140
  uriorcurie: "uri"
974
1141
  };
975
- var CLASS_KEYS = /* @__PURE__ */ new Set(["description", "annotations", "close_mappings", "attributes", "slots"]);
1142
+ var CLASS_KEYS = /* @__PURE__ */ new Set([
1143
+ "description",
1144
+ "is_a",
1145
+ "annotations",
1146
+ "close_mappings",
1147
+ "attributes",
1148
+ "slots"
1149
+ ]);
976
1150
  var SLOT_KEYS = /* @__PURE__ */ new Set(["description", "annotations", "range", "multivalued", "identifier"]);
977
1151
  var CLASS_TAGS = /* @__PURE__ */ new Set(["system", "note", "undeclared"]);
978
1152
  var SLOT_TAGS = /* @__PURE__ */ new Set(["system", "note", "joins_to"]);
@@ -990,6 +1164,7 @@ var QUIET_SCHEMA_KEYS = /* @__PURE__ */ new Set([
990
1164
  "slots",
991
1165
  "enums"
992
1166
  ]);
1167
+ var REWORDED = "a standalone `?` is attached to the word before it";
993
1168
  var UNKNOWN_TYPE = /^[a-z][a-z0-9_-]*$/;
994
1169
  function fromLinkML(schema) {
995
1170
  const root = asRecord(schema);
@@ -1014,7 +1189,7 @@ function fromLinkML(schema) {
1014
1189
  for (const key2 of Object.keys(classDefs)) {
1015
1190
  const names = /* @__PURE__ */ new Map();
1016
1191
  const taken = /* @__PURE__ */ new Set();
1017
- for (const slot of slotKeysOf(asRecord(get(classDefs, key2)) ?? {})) {
1192
+ for (const slot of fieldKeysOf(key2)) {
1018
1193
  names.set(slot, unique(toFieldName(slot), taken));
1019
1194
  }
1020
1195
  fieldNames.set(key2, names);
@@ -1033,7 +1208,7 @@ function fromLinkML(schema) {
1033
1208
  const rawValues = get(asRecord(get(enumDefs, key2)) ?? {}, "permissible_values");
1034
1209
  const values = asRecord(rawValues);
1035
1210
  const keys = Object.keys(values ?? {});
1036
- const bad = keys.find((value) => !SYSTEM_OR_VALUE.test(value));
1211
+ const bad = keys.find((value) => !ENUM_VALUE.test(value));
1037
1212
  if (values === void 0 && present(rawValues)) {
1038
1213
  unwritable.set(key2, `${shown(rawValues)} is not a map of permissible values`);
1039
1214
  } else if (keys.length < 2) {
@@ -1064,14 +1239,32 @@ function fromLinkML(schema) {
1064
1239
  }
1065
1240
  continue;
1066
1241
  }
1242
+ const undeclared = annotations.get("undeclared");
1243
+ if (undeclared !== void 0 && typeof undeclared !== "boolean") {
1244
+ report("annotation", name2, notText("undeclared", undeclared));
1245
+ }
1067
1246
  const node = { name: at0(name2), fields: [], line: 0 };
1068
1247
  const description = oneLine(stringOf(get(def, "description")));
1069
- if (description !== void 0) node.description = description;
1248
+ if (description !== void 0) {
1249
+ node.description = description;
1250
+ if (rewordsDescription(description)) report("reworded", name2, REWORDED);
1251
+ }
1070
1252
  const system = annotations.get("system");
1071
- if (typeof system === "string" && SYSTEM_OR_VALUE.test(system)) node.system = at0(system);
1072
- else if (system !== void 0) report("annotation", name2, "`system` is not a system name");
1073
- const note = oneLine(stringOf(annotations.get("note")));
1074
- if (note !== void 0) node.note = note;
1253
+ if (typeof system === "string") {
1254
+ if (SYSTEM_NAME.test(system)) node.system = at0(system);
1255
+ else report("annotation", name2, "`system` is not a system name");
1256
+ } else if (system !== void 0) report("annotation", name2, notText("system", system));
1257
+ const rawNote = annotations.get("note");
1258
+ if (typeof rawNote === "string") {
1259
+ const note = oneLine(rawNote);
1260
+ if (note !== void 0) node.note = note;
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
+ }
1075
1268
  const mappings = stringList(get(def, "close_mappings"));
1076
1269
  const first = mappings[0];
1077
1270
  const target = first === void 0 ? "" : localName(first);
@@ -1094,6 +1287,9 @@ function fromLinkML(schema) {
1094
1287
  }
1095
1288
  }
1096
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
+ }
1097
1293
  if (other === "attributes" && present(block) && asRecord(block) === void 0) {
1098
1294
  report("attributes", name2, `${shown(block)} is not a map`);
1099
1295
  }
@@ -1108,10 +1304,12 @@ function fromLinkML(schema) {
1108
1304
  report("slot_usage", name2);
1109
1305
  continue;
1110
1306
  }
1307
+ const written = slotKeysOf(def);
1308
+ const inherited = inheritedSlots(key2);
1111
1309
  for (const slot of Object.keys(usage)) {
1112
- if (fieldNames.get(key2)?.has(slot) === true) {
1310
+ if (written.includes(slot)) {
1113
1311
  report("slot_usage", `${name2}.${fieldNameOf(key2, slot)}`);
1114
- } else {
1312
+ } else if (!inherited.has(slot)) {
1115
1313
  report("slot_usage", name2, `\`${slot}\` is inherited and not in the sketch`);
1116
1314
  }
1117
1315
  }
@@ -1119,9 +1317,10 @@ function fromLinkML(schema) {
1119
1317
  }
1120
1318
  report(other, name2);
1121
1319
  }
1122
- for (const slot of slotKeysOf(def)) {
1320
+ const own = slotKeysOf(def);
1321
+ for (const slot of fieldKeysOf(key2)) {
1123
1322
  const attributes = asRecord(get(def, "attributes")) ?? {};
1124
- 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);
1125
1324
  const slotDef = asRecord(rawSlot);
1126
1325
  if (slotDef === void 0 && present(rawSlot)) {
1127
1326
  report("slot", `${name2}.${fieldNameOf(key2, slot)}`, "is not a slot definition");
@@ -1176,13 +1375,18 @@ function fromLinkML(schema) {
1176
1375
  line: 0
1177
1376
  };
1178
1377
  const description = oneLine(stringOf(get(def, "description")));
1179
- if (description !== void 0) field.description = description;
1378
+ if (description !== void 0) {
1379
+ field.description = description;
1380
+ if (rewordsDescription(description)) report("reworded", element, REWORDED);
1381
+ }
1180
1382
  const type = typeFor(element, name2, def);
1181
1383
  if (type !== void 0) field.type = type;
1182
1384
  const annotations = annotationsOf(def);
1183
1385
  const system = annotations.get("system");
1184
- if (typeof system === "string" && SYSTEM_OR_VALUE.test(system)) field.system = at0(system);
1185
- else if (system !== void 0) report("annotation", element, "`system` is not a system name");
1386
+ if (typeof system === "string") {
1387
+ if (SYSTEM_NAME.test(system)) field.system = at0(system);
1388
+ else report("annotation", element, "`system` is not a system name");
1389
+ } else if (system !== void 0) report("annotation", element, notText("system", system));
1186
1390
  const joins = annotations.get("joins_to");
1187
1391
  if (typeof joins === "string") {
1188
1392
  const dot = joins.indexOf(".");
@@ -1195,9 +1399,12 @@ function fromLinkML(schema) {
1195
1399
  reference(element, target, targetClass);
1196
1400
  reference(element, targetField, targetName);
1197
1401
  } else report("annotation", element, "`joins_to` is not `Class.field`");
1198
- } else if (joins !== void 0) report("annotation", element, "`joins_to` is not text");
1199
- const note = oneLine(stringOf(annotations.get("note")));
1200
- if (note !== void 0) field.note = note;
1402
+ } else if (joins !== void 0) report("annotation", element, notText("joins_to", joins));
1403
+ const rawNote = annotations.get("note");
1404
+ if (typeof rawNote === "string") {
1405
+ const note = oneLine(rawNote);
1406
+ if (note !== void 0) field.note = note;
1407
+ } else if (rawNote !== void 0) report("annotation", element, notText("note", rawNote));
1201
1408
  for (const other of Object.keys(def)) {
1202
1409
  if (!SLOT_KEYS.has(other)) {
1203
1410
  report(other, element);
@@ -1250,6 +1457,34 @@ function fromLinkML(schema) {
1250
1457
  report("narrowed", element, `\`${range}\` is not a Skiss type; it falls back to string`);
1251
1458
  return { kind: "unknown", name: at0(word), many };
1252
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
+ }
1253
1488
  function listedSomewhere(slot) {
1254
1489
  for (const key2 of Object.keys(classDefs)) {
1255
1490
  const def = asRecord(get(classDefs, key2)) ?? {};
@@ -1272,13 +1507,17 @@ var COUNTED = {
1272
1507
  class: ["class", "classes"],
1273
1508
  slot: ["slot", "slots"],
1274
1509
  schema: ["schema key", "schema keys"],
1510
+ narrowed: ["narrowed range", "narrowed ranges"],
1511
+ enum_detail: ["enum detail", "enum details"],
1512
+ annotation: ["unreadable annotation", "unreadable annotations"],
1513
+ reworded: ["description reworded", "descriptions reworded"],
1275
1514
  // `mixin: true` marks the class; `mixins: [M]` points at another one.
1276
1515
  mixin: ["mixin class", "mixin classes"],
1277
1516
  mixins: ["mixin", "mixins"],
1278
1517
  pattern: ["pattern", "patterns"],
1279
1518
  unit: ["unit", "units"]
1280
1519
  };
1281
- var ENUM_KINDS = /* @__PURE__ */ new Set(["enum_detail"]);
1520
+ var UNREADABLE = "unreadable";
1282
1521
  function formatDropped(dropped) {
1283
1522
  const groups = /* @__PURE__ */ new Map();
1284
1523
  for (const entry of dropped) {
@@ -1287,15 +1526,21 @@ function formatDropped(dropped) {
1287
1526
  else group.push(entry);
1288
1527
  }
1289
1528
  const drops = [];
1529
+ const notRead = [];
1290
1530
  let renamed = "";
1291
1531
  let inlined = "";
1292
1532
  for (const [kind, entries] of groups) {
1533
+ if (kind === UNREADABLE) {
1534
+ for (const entry of entries) notRead.push(entry.detail ?? "the schema could not be read");
1535
+ continue;
1536
+ }
1293
1537
  const phrase = phraseFor(kind, entries);
1294
1538
  if (kind === "renamed") renamed = phrase;
1295
1539
  else if (kind === "inlined") inlined = phrase;
1296
1540
  else drops.push(phrase);
1297
1541
  }
1298
1542
  const sentences = [];
1543
+ if (notRead.length > 0) sentences.push(`Not read: ${notRead.join("; ")}.`);
1299
1544
  if (drops.length > 0) sentences.push(`Dropped: ${drops.join(", ")}.`);
1300
1545
  if (renamed !== "") sentences.push(`Renamed: ${renamed}.`);
1301
1546
  if (inlined !== "") sentences.push(`Inlined: ${inlined}.`);
@@ -1305,11 +1550,10 @@ function phraseFor(kind, entries) {
1305
1550
  const n = entries.length;
1306
1551
  const counted = Object.hasOwn(COUNTED, kind) ? COUNTED[kind] : void 0;
1307
1552
  if (counted !== void 0) return `${n} ${n === 1 ? counted[0] : counted[1]}`;
1308
- return `${kind} on ${n} ${nounFor(kind, entries)}`;
1553
+ return `${kind} on ${n} ${nounFor(entries)}`;
1309
1554
  }
1310
- function nounFor(kind, entries) {
1555
+ function nounFor(entries) {
1311
1556
  const one = entries.length === 1;
1312
- if (ENUM_KINDS.has(kind)) return one ? "enum" : "enums";
1313
1557
  const slots = entries.filter((entry) => entry.element.includes(".")).length;
1314
1558
  if (slots === entries.length) return one ? "slot" : "slots";
1315
1559
  if (slots === 0) return one ? "class" : "classes";
@@ -1360,12 +1604,18 @@ function annotationsOf(def) {
1360
1604
  if (annotations === void 0) return out;
1361
1605
  for (const tag of Object.keys(annotations)) {
1362
1606
  const raw = get(annotations, tag);
1363
- const value = asRecord(raw) === void 0 ? raw : get(asRecord(raw) ?? {}, "value");
1364
- if (typeof value === "string" || typeof value === "boolean") out.set(tag, value);
1365
- else out.set(tag, String(value));
1607
+ const long = asRecord(raw);
1608
+ const value = long === void 0 ? raw : get(long, "value");
1609
+ out.set(tag, value ?? null);
1366
1610
  }
1367
1611
  return out;
1368
1612
  }
1613
+ function notText(tag, value) {
1614
+ if (value === null) return `\`${tag}\` was written with no value`;
1615
+ if (Array.isArray(value)) return `\`${tag}\` is a list, not text`;
1616
+ if (typeof value === "object") return `\`${tag}\` is a map, not text`;
1617
+ return `\`${tag}\` is a ${typeof value}, not text`;
1618
+ }
1369
1619
  function slotKeysOf(def) {
1370
1620
  const keys = Object.keys(asRecord(get(def, "attributes")) ?? {});
1371
1621
  for (const slot of stringList(get(def, "slots"))) {
@@ -1390,7 +1640,7 @@ var schemaElement = (root) => stringOf(get(root, "name")) ?? "schema";
1390
1640
  var nothing = (element, detail) => ({
1391
1641
  document: parse(""),
1392
1642
  source: "",
1393
- dropped: [{ kind: "schema", element, detail }]
1643
+ dropped: [{ kind: UNREADABLE, element, detail }]
1394
1644
  });
1395
1645
 
1396
1646
  // src/import.ts
@@ -1417,7 +1667,7 @@ function notYAML(error) {
1417
1667
  }
1418
1668
 
1419
1669
  // src/index.ts
1420
- var VERSION = "0.3.0";
1670
+ var VERSION = "0.5.0";
1421
1671
 
1422
1672
  // src/cli.ts
1423
1673
  var HELP = `Usage: skiss <command> [options]
@@ -1426,15 +1676,22 @@ Commands:
1426
1676
  diagram <file> Write a Mermaid class diagram for a .skiss file.
1427
1677
  compile <file> Write a LinkML schema for a .skiss file.
1428
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.
1429
1681
  <file> may be - to read standard input.
1430
1682
 
1431
1683
  Options:
1432
1684
  -o, --output <path> Write the output to <path> instead of standard output
1433
- --notes diagram: emit \`? text\` doubts as Mermaid notes
1685
+ --notes diagram, render: emit \`? text\` doubts as Mermaid notes
1434
1686
  --json compile: write the schema as JSON instead of YAML
1435
1687
  --name <name> compile: the schema name. Defaults to the file's
1436
1688
  basename without its extension, or \`sketch\` for
1437
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.
1438
1695
  --strict Exit 1 when the input has any diagnostic; for import,
1439
1696
  also when the projection dropped anything
1440
1697
  -h, --help Show this help and exit
@@ -1450,8 +1707,12 @@ wrote has, so their line numbers are lines of that sketch.
1450
1707
  Exit codes:
1451
1708
  0 the output was produced
1452
1709
  1 --strict and the input has diagnostics (the output is still written)
1453
- 2 usage error, missing file or unreadable input
1710
+ 2 usage error, missing file, or unreadable input: for import, a text no
1711
+ schema could be read out of, which produces no sketch at all; for
1712
+ render, the Mermaid CLI not being installed
1454
1713
  70 internal error, a bug in skiss
1714
+
1715
+ render exits with mmdc's own status when mmdc ran and failed.
1455
1716
  `;
1456
1717
  var EXIT_OK = 0;
1457
1718
  var EXIT_STRICT = 1;
@@ -1460,7 +1721,8 @@ var EXIT_INTERNAL = 70;
1460
1721
  var COMMANDS = {
1461
1722
  diagram: ["output", "notes", "strict"],
1462
1723
  compile: ["output", "json", "name", "strict"],
1463
- import: ["output", "strict"]
1724
+ import: ["output", "strict"],
1725
+ render: ["output", "notes", "format", "scale", "strict"]
1464
1726
  };
1465
1727
  var isCommand = (text) => Object.hasOwn(COMMANDS, text);
1466
1728
  var UsageError = class extends Error {
@@ -1482,6 +1744,8 @@ async function main(argv) {
1482
1744
  notes: { type: "boolean" },
1483
1745
  json: { type: "boolean" },
1484
1746
  name: { type: "string" },
1747
+ format: { type: "string" },
1748
+ scale: { type: "string" },
1485
1749
  strict: { type: "boolean" },
1486
1750
  help: { type: "boolean", short: "h" },
1487
1751
  version: { type: "boolean" }
@@ -1511,8 +1775,16 @@ async function main(argv) {
1511
1775
  }
1512
1776
  }
1513
1777
  const source = file === "-" ? await readStdin() : await readSource(file);
1514
- const { output, diagnostics, dropped } = runCommand(command, source, file, values);
1515
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);
1516
1788
  const report = formatDropped(dropped);
1517
1789
  if (report !== "") process.stderr.write(`${report}
1518
1790
  `);
@@ -1520,9 +1792,15 @@ async function main(argv) {
1520
1792
  `);
1521
1793
  if (values.output === void 0) process.stdout.write(output);
1522
1794
  else await writeOutput(values.output, output);
1795
+ if (unreadableInput(command, diagnostics, dropped)) return EXIT_USAGE;
1523
1796
  const strict = values.strict === true && (diagnostics.length > 0 || dropped.length > 0);
1524
1797
  return strict ? EXIT_STRICT : EXIT_OK;
1525
1798
  }
1799
+ function unreadableInput(command, diagnostics, dropped) {
1800
+ if (command !== "import") return false;
1801
+ if (dropped.some((entry) => entry.kind === "unreadable")) return true;
1802
+ return diagnostics.some((d) => d.code === "E_NOT_YAML");
1803
+ }
1526
1804
  function runCommand(command, source, file, values) {
1527
1805
  if (command === "import") return importLinkML(source);
1528
1806
  if (command === "compile") {
@@ -1561,6 +1839,76 @@ async function writeOutput(path, text) {
1561
1839
  throw new UsageError(`cannot write \`${path}\`: ${reason(error)}`);
1562
1840
  }
1563
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
+ }
1564
1912
  function reason(error) {
1565
1913
  if (error instanceof Error) {
1566
1914
  const code = error.code;
@@ -1568,6 +1916,12 @@ function reason(error) {
1568
1916
  }
1569
1917
  return String(error);
1570
1918
  }
1919
+ for (const stream of [process.stdout, process.stderr]) {
1920
+ stream.on("error", (error) => {
1921
+ if (error.code === "EPIPE") process.exit(EXIT_OK);
1922
+ throw error;
1923
+ });
1924
+ }
1571
1925
  main(process.argv.slice(2)).then(
1572
1926
  (code) => {
1573
1927
  process.exitCode = code;