@dxos/diagram 0.11.1 → 0.13.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/lib/chunk-parse.mjs +9396 -0
- package/dist/lib/chunk-parse.mjs.map +1 -0
- package/dist/lib/extension.mjs +204 -0
- package/dist/lib/extension.mjs.map +1 -0
- package/dist/lib/index.mjs +497 -2846
- package/dist/lib/index.mjs.map +1 -1
- package/dist/types/src/aesthetics.d.ts +12 -0
- package/dist/types/src/aesthetics.d.ts.map +1 -0
- package/dist/types/src/aesthetics.test.d.ts +2 -0
- package/dist/types/src/aesthetics.test.d.ts.map +1 -0
- package/dist/types/src/appeal.d.ts +55 -0
- package/dist/types/src/appeal.d.ts.map +1 -0
- package/dist/types/src/appeal.test.d.ts +2 -0
- package/dist/types/src/appeal.test.d.ts.map +1 -0
- package/dist/types/src/architecture.d.ts +86 -0
- package/dist/types/src/architecture.d.ts.map +1 -0
- package/dist/types/src/architecture.test.d.ts +2 -0
- package/dist/types/src/architecture.test.d.ts.map +1 -0
- package/dist/types/src/compact.d.ts +39 -0
- package/dist/types/src/compact.d.ts.map +1 -0
- package/dist/types/src/compact.test.d.ts +2 -0
- package/dist/types/src/compact.test.d.ts.map +1 -0
- package/dist/types/src/diagnostics.d.ts +70 -3
- package/dist/types/src/diagnostics.d.ts.map +1 -1
- package/dist/types/src/dsl/cli.d.ts +2 -0
- package/dist/types/src/dsl/cli.d.ts.map +1 -0
- package/dist/types/src/dsl/compile.d.ts +12 -0
- package/dist/types/src/dsl/compile.d.ts.map +1 -0
- package/dist/types/src/dsl/convert.d.ts +45 -0
- package/dist/types/src/dsl/convert.d.ts.map +1 -0
- package/dist/types/src/dsl/convert.test.d.ts +2 -0
- package/dist/types/src/dsl/convert.test.d.ts.map +1 -0
- package/dist/types/src/dsl/dsl.test.d.ts +2 -0
- package/dist/types/src/dsl/dsl.test.d.ts.map +1 -0
- package/dist/types/src/dsl/gen/diagram.d.ts +3 -0
- package/dist/types/src/dsl/gen/diagram.d.ts.map +1 -0
- package/dist/types/src/dsl/index.d.ts +7 -0
- package/dist/types/src/dsl/index.d.ts.map +1 -0
- package/dist/types/src/dsl/parse.d.ts +49 -0
- package/dist/types/src/dsl/parse.d.ts.map +1 -0
- package/dist/types/src/dsl/print.d.ts +19 -0
- package/dist/types/src/dsl/print.d.ts.map +1 -0
- package/dist/types/src/dsl/semantic.test.d.ts +2 -0
- package/dist/types/src/dsl/semantic.test.d.ts.map +1 -0
- package/dist/types/src/dsl/vocabulary.d.ts +61 -0
- package/dist/types/src/dsl/vocabulary.d.ts.map +1 -0
- package/dist/types/src/dx-svg.d.ts +12 -0
- package/dist/types/src/dx-svg.d.ts.map +1 -0
- package/dist/types/src/dx-svg.test.d.ts +2 -0
- package/dist/types/src/dx-svg.test.d.ts.map +1 -0
- package/dist/types/src/extension/complete.d.ts +3 -0
- package/dist/types/src/extension/complete.d.ts.map +1 -0
- package/dist/types/src/extension/extension.test.d.ts +2 -0
- package/dist/types/src/extension/extension.test.d.ts.map +1 -0
- package/dist/types/src/extension/highlight.d.ts +14 -0
- package/dist/types/src/extension/highlight.d.ts.map +1 -0
- package/dist/types/src/extension/index.d.ts +6 -0
- package/dist/types/src/extension/index.d.ts.map +1 -0
- package/dist/types/src/extension/language.d.ts +13 -0
- package/dist/types/src/extension/language.d.ts.map +1 -0
- package/dist/types/src/extension/lint.d.ts +21 -0
- package/dist/types/src/extension/lint.d.ts.map +1 -0
- package/dist/types/src/extension/syntax.d.ts +3 -0
- package/dist/types/src/extension/syntax.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +14 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/mermaid-engine.d.ts +40 -1
- package/dist/types/src/mermaid-engine.d.ts.map +1 -1
- package/dist/types/src/mermaid.d.ts +3 -3
- package/dist/types/src/mermaid.d.ts.map +1 -1
- package/dist/types/src/nudge.d.ts +30 -0
- package/dist/types/src/nudge.d.ts.map +1 -0
- package/dist/types/src/nudge.test.d.ts +2 -0
- package/dist/types/src/nudge.test.d.ts.map +1 -0
- package/dist/types/src/objective.d.ts +4 -0
- package/dist/types/src/objective.d.ts.map +1 -1
- package/dist/types/src/ortho-router.d.ts +13 -1
- package/dist/types/src/ortho-router.d.ts.map +1 -1
- package/dist/types/src/ports.d.ts +40 -0
- package/dist/types/src/ports.d.ts.map +1 -0
- package/dist/types/src/ports.test.d.ts +2 -0
- package/dist/types/src/ports.test.d.ts.map +1 -0
- package/dist/types/src/relations.test.d.ts +2 -0
- package/dist/types/src/relations.test.d.ts.map +1 -0
- package/dist/types/src/rules.d.ts +30 -0
- package/dist/types/src/rules.d.ts.map +1 -0
- package/dist/types/src/scene.d.ts +99 -65
- package/dist/types/src/scene.d.ts.map +1 -1
- package/dist/types/src/score.d.ts +54 -0
- package/dist/types/src/score.d.ts.map +1 -0
- package/dist/types/src/score.test.d.ts +2 -0
- package/dist/types/src/score.test.d.ts.map +1 -0
- package/dist/types/src/semantic-engine.d.ts +41 -0
- package/dist/types/src/semantic-engine.d.ts.map +1 -0
- package/dist/types/src/semantic-engine.test.d.ts +2 -0
- package/dist/types/src/semantic-engine.test.d.ts.map +1 -0
- package/dist/types/src/semantic-place.d.ts +81 -0
- package/dist/types/src/semantic-place.d.ts.map +1 -0
- package/dist/types/src/semantic-route.d.ts +133 -0
- package/dist/types/src/semantic-route.d.ts.map +1 -0
- package/dist/types/src/semantic.d.ts +164 -0
- package/dist/types/src/semantic.d.ts.map +1 -0
- package/dist/types/src/uml-grid.d.ts.map +1 -1
- package/dist/types/src/uml.d.ts +3 -5
- package/dist/types/src/uml.d.ts.map +1 -1
- package/dist/types/src/view.d.ts +53 -0
- package/dist/types/src/view.d.ts.map +1 -0
- package/dist/types/src/view.test.d.ts +2 -0
- package/dist/types/src/view.test.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +23 -5
- package/rules/DIAGRAM.mdl +388 -0
- package/src/aesthetics.test.ts +89 -0
- package/src/aesthetics.ts +148 -0
- package/src/appeal.test.ts +144 -0
- package/src/appeal.ts +586 -0
- package/src/architecture.test.ts +94 -0
- package/src/architecture.ts +215 -0
- package/src/compact.test.ts +67 -0
- package/src/compact.ts +219 -0
- package/src/diagnostics.test.ts +207 -1
- package/src/diagnostics.ts +481 -37
- package/src/dsl/cli.ts +46 -0
- package/src/dsl/compile.ts +28 -0
- package/src/dsl/convert.test.ts +51 -0
- package/src/dsl/convert.ts +98 -0
- package/src/dsl/diagram.grammar +195 -0
- package/src/dsl/dsl.test.ts +286 -0
- package/src/dsl/gen/diagram.ts +22 -0
- package/src/dsl/index.ts +44 -0
- package/src/dsl/parse.ts +1054 -0
- package/src/dsl/print.ts +198 -0
- package/src/dsl/semantic.test.ts +200 -0
- package/src/dsl/vocabulary.ts +150 -0
- package/src/dx-svg.test.ts +44 -0
- package/src/dx-svg.ts +77 -0
- package/src/extension/complete.ts +105 -0
- package/src/extension/extension.test.ts +135 -0
- package/src/extension/highlight.ts +23 -0
- package/src/extension/index.ts +9 -0
- package/src/extension/language.ts +31 -0
- package/src/extension/lint.ts +60 -0
- package/src/extension/syntax.ts +62 -0
- package/src/index.ts +14 -0
- package/src/mermaid-engine.test.ts +6 -6
- package/src/mermaid-engine.ts +569 -119
- package/src/mermaid.test.ts +17 -1
- package/src/mermaid.ts +30 -18
- package/src/nudge.test.ts +155 -0
- package/src/nudge.ts +385 -0
- package/src/objective.test.ts +2 -0
- package/src/objective.ts +17 -1
- package/src/ortho-router.ts +240 -80
- package/src/ports.test.ts +157 -0
- package/src/ports.ts +186 -0
- package/src/relations.test.ts +98 -0
- package/src/rules.ts +117 -0
- package/src/scene.ts +64 -3
- package/src/score.test.ts +64 -0
- package/src/score.ts +130 -0
- package/src/semantic-engine.test.ts +635 -0
- package/src/semantic-engine.ts +888 -0
- package/src/semantic-place.ts +1508 -0
- package/src/semantic-route.ts +1136 -0
- package/src/semantic.ts +278 -0
- package/src/typings.d.ts +8 -0
- package/src/uml-grid.ts +6 -1
- package/src/uml.test.ts +5 -1
- package/src/uml.ts +24 -11
- package/src/view.test.ts +139 -0
- package/src/view.ts +376 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type CompletionContext, type CompletionResult, autocompletion } from '@codemirror/autocomplete';
|
|
6
|
+
import { syntaxTree } from '@codemirror/language';
|
|
7
|
+
import { type Extension } from '@codemirror/state';
|
|
8
|
+
import { type SyntaxNode } from '@lezer/common';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
type AttrSpec,
|
|
12
|
+
DIAGRAM_ATTRS,
|
|
13
|
+
EDGE_ATTRS,
|
|
14
|
+
ELEMENT_ATTRS,
|
|
15
|
+
ELEMENT_KINDS,
|
|
16
|
+
type ElementKind,
|
|
17
|
+
GROUP_ATTRS,
|
|
18
|
+
NODE_ATTRS,
|
|
19
|
+
OBJECT_ATTRS,
|
|
20
|
+
RELATION_WORDS,
|
|
21
|
+
STATEMENT_KEYWORDS,
|
|
22
|
+
} from '../dsl/vocabulary.ts';
|
|
23
|
+
|
|
24
|
+
/** What a semantic statement accepts after its id: attributes, plus relations or edge clauses. */
|
|
25
|
+
const SEMANTIC: Record<string, { attrs: readonly AttrSpec[]; words: readonly string[] }> = {
|
|
26
|
+
DiagramDecl: { attrs: DIAGRAM_ATTRS, words: [] },
|
|
27
|
+
GroupDecl: { attrs: GROUP_ATTRS, words: [...RELATION_WORDS, 'compact'] },
|
|
28
|
+
NodeDecl: { attrs: NODE_ATTRS, words: RELATION_WORDS },
|
|
29
|
+
EdgeDecl: { attrs: EDGE_ATTRS, words: ['via', 'bus'] },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const attributeOptions = (attrs: readonly AttrSpec[]) =>
|
|
33
|
+
attrs.map(({ name, type }) => ({
|
|
34
|
+
label: name,
|
|
35
|
+
type: 'property',
|
|
36
|
+
detail: type === 'enum' ? undefined : type,
|
|
37
|
+
apply: `${name}=`,
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
/** The element kind a completion is inside, read off the tree rather than re-lexing the line. */
|
|
41
|
+
const enclosingKind = (context: CompletionContext, pos: number): ElementKind | undefined => {
|
|
42
|
+
let node = syntaxTree(context.state).resolveInner(pos, -1);
|
|
43
|
+
while (node.parent) {
|
|
44
|
+
if (node.name === 'Element') {
|
|
45
|
+
const shape = node.firstChild;
|
|
46
|
+
const keyword = shape && (shape.getChild('BoxKind') ?? shape.getChild('PathKind'))?.firstChild;
|
|
47
|
+
const word = context.state.doc.sliceString(
|
|
48
|
+
(keyword ?? shape?.firstChild)?.from ?? 0,
|
|
49
|
+
(keyword ?? shape?.firstChild)?.to ?? 0,
|
|
50
|
+
);
|
|
51
|
+
return ELEMENT_KINDS.find((kind) => kind === word);
|
|
52
|
+
}
|
|
53
|
+
if (node.name === 'ObjectDecl' || node.name === 'ElementsDecl') {
|
|
54
|
+
// Inside the declaration but outside any element: the object's own attributes.
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
node = node.parent;
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Node names from the cursor out to the document, so a position is classified by where it sits. */
|
|
63
|
+
const ancestorsOf = (context: CompletionContext, pos: number): string[] => {
|
|
64
|
+
const names: string[] = [];
|
|
65
|
+
for (let node: SyntaxNode | null = syntaxTree(context.state).resolveInner(pos, -1); node; node = node.parent) {
|
|
66
|
+
names.push(node.name);
|
|
67
|
+
}
|
|
68
|
+
return names;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const source = (context: CompletionContext): CompletionResult | null => {
|
|
72
|
+
const word = context.matchBefore(/[\w-]*/);
|
|
73
|
+
if (!word || (word.from === word.to && !context.explicit)) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const ancestors = ancestorsOf(context, context.pos);
|
|
78
|
+
// Inside a semantic statement the innermost one decides; a group's body holds nodes and edges.
|
|
79
|
+
const innermost = ancestors.find((name) => name in SEMANTIC || name === 'GroupBody');
|
|
80
|
+
if (innermost === 'GroupBody') {
|
|
81
|
+
return { from: word.from, options: ['node', 'edge'].map((label) => ({ label, type: 'keyword' })) };
|
|
82
|
+
}
|
|
83
|
+
if (innermost) {
|
|
84
|
+
const { attrs, words } = SEMANTIC[innermost];
|
|
85
|
+
return {
|
|
86
|
+
from: word.from,
|
|
87
|
+
options: [...words.map((label) => ({ label, type: 'keyword' })), ...attributeOptions(attrs)],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
// Outside any declaration only a statement can start.
|
|
91
|
+
if (!ancestors.includes('ObjectDecl') && !ancestors.includes('ElementsDecl')) {
|
|
92
|
+
return { from: word.from, options: STATEMENT_KEYWORDS.map((label) => ({ label, type: 'keyword' })) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A `Body` holds elements and nothing else, so an empty position in one wants a kind; inside an
|
|
96
|
+
// element it wants that kind's attributes, and in the header it wants the object's.
|
|
97
|
+
const kind = enclosingKind(context, context.pos);
|
|
98
|
+
if (!kind && ancestors.includes('Body')) {
|
|
99
|
+
return { from: word.from, options: ELEMENT_KINDS.map((label) => ({ label, type: 'keyword' })) };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { from: word.from, options: attributeOptions(kind ? ELEMENT_ATTRS[kind] : OBJECT_ATTRS) };
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const diagramComplete: Extension = autocompletion({ override: [source] });
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { HighlightStyle } from '@codemirror/language';
|
|
6
|
+
import { highlightTree } from '@lezer/highlight';
|
|
7
|
+
import { vscodeDarkStyle, vscodeLightStyle } from '@uiw/codemirror-theme-vscode';
|
|
8
|
+
import { describe, test } from 'vitest';
|
|
9
|
+
|
|
10
|
+
import { printCommands } from '../dsl/print.ts';
|
|
11
|
+
import { diagramHighlightStyle } from './highlight.ts';
|
|
12
|
+
import { diagram } from './language.ts';
|
|
13
|
+
import { diagramDiagnostics } from './lint.ts';
|
|
14
|
+
import { diagramLanguage } from './syntax.ts';
|
|
15
|
+
|
|
16
|
+
describe('diagram language', () => {
|
|
17
|
+
test('the mode composes without an editor', ({ expect }) => {
|
|
18
|
+
expect(diagram({ layout: true })).toBeDefined();
|
|
19
|
+
expect(diagramLanguage.name).toEqual('diagram');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('highlighting comes off the tree, not a regex', ({ expect }) => {
|
|
23
|
+
const source = 'object A @ 0,0 {\n rect box 0,0 10x10 "hi" color=red\n}\n';
|
|
24
|
+
const tree = diagramLanguage.parser.parse(source);
|
|
25
|
+
const names: string[] = [];
|
|
26
|
+
const cursor = tree.cursor();
|
|
27
|
+
do {
|
|
28
|
+
names.push(cursor.name);
|
|
29
|
+
} while (cursor.next());
|
|
30
|
+
expect(names).toContain('ObjectDecl');
|
|
31
|
+
expect(names).toContain('BoxElement');
|
|
32
|
+
expect(names).toContain('AttrName');
|
|
33
|
+
expect(names).not.toContain('⚠');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
//
|
|
37
|
+
// The tags are standard so the theme colours most of a document, but `vscode*Style` defines
|
|
38
|
+
// neither `attributeValue` nor `null` — enum values and the unbound arrow end rendered plain
|
|
39
|
+
// until `diagramHighlightStyle` filled them. Asserting against the real theme is what catches
|
|
40
|
+
// the next tag added to `styleTags` with nothing to colour it.
|
|
41
|
+
//
|
|
42
|
+
for (const [mode, themeStyle] of [
|
|
43
|
+
['light', vscodeLightStyle],
|
|
44
|
+
['dark', vscodeDarkStyle],
|
|
45
|
+
] as const) {
|
|
46
|
+
test(`every token is coloured under the ${mode} theme`, ({ expect }) => {
|
|
47
|
+
expect(uncoloured(SAMPLE, themeStyle)).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test(`every semantic token is coloured under the ${mode} theme`, ({ expect }) => {
|
|
51
|
+
expect(uncoloured(SEMANTIC_SAMPLE, themeStyle)).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/** Every element kind, both arrow forms, a quoted id, a comment — one of each tag the grammar emits. */
|
|
57
|
+
const SAMPLE = `# comment
|
|
58
|
+
object pkgA @ -24,-24 scale=1 index="a1" {
|
|
59
|
+
rect frame 0,0 248x172 "Package A" color=grey stroke=dashed
|
|
60
|
+
line path 0,0 10,10 closed=true
|
|
61
|
+
arc smile 50,50 20 0..180
|
|
62
|
+
arrow e1 A/box#left -> B/box "extends" head=triangle
|
|
63
|
+
arrow e2 10,20 -> _
|
|
64
|
+
portal "1st" 0,0 10x10 ref="dxn:echo:@:01"
|
|
65
|
+
}
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
/** The semantic statements, with soft sides, a compact group, an aspect ratio and a fan-in bus. */
|
|
69
|
+
const SEMANTIC_SAMPLE = `diagram flow=down aspect=4:3
|
|
70
|
+
group g "G" compact max-width=2 ~right-of h { node A node B below A }
|
|
71
|
+
group h { node C }
|
|
72
|
+
edge A:~left|top, B -> C "joins" bus
|
|
73
|
+
`;
|
|
74
|
+
|
|
75
|
+
/** Non-whitespace runs that no highlighter claimed, as `offset:"text"`. */
|
|
76
|
+
const uncoloured = (source: string, themeStyle: Parameters<typeof HighlightStyle.define>[0]): string[] => {
|
|
77
|
+
const covered = new Uint8Array(source.length);
|
|
78
|
+
for (const style of [HighlightStyle.define(themeStyle), diagramHighlightStyle()]) {
|
|
79
|
+
highlightTree(diagramLanguage.parser.parse(source), style, (from, to, classes) => {
|
|
80
|
+
if (classes) {
|
|
81
|
+
covered.fill(1, from, to);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const gaps: string[] = [];
|
|
87
|
+
let run = '';
|
|
88
|
+
let start = 0;
|
|
89
|
+
for (let index = 0; index <= source.length; index++) {
|
|
90
|
+
const bare = index < source.length && !covered[index] && !/\s/.test(source[index]);
|
|
91
|
+
if (bare) {
|
|
92
|
+
if (!run) {
|
|
93
|
+
start = index;
|
|
94
|
+
}
|
|
95
|
+
run += source[index];
|
|
96
|
+
} else if (run) {
|
|
97
|
+
gaps.push(`${start}:${JSON.stringify(run)}`);
|
|
98
|
+
run = '';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return gaps;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
describe('diagram lint', () => {
|
|
105
|
+
test('a clean document reports nothing', ({ expect }) => {
|
|
106
|
+
expect(diagramDiagnostics('object A @ 0,0 {\n rect box 0,0 10x10\n}\n', { layout: true })).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('parse problems carry a range inside the document', ({ expect }) => {
|
|
110
|
+
const source = 'object A @ 0,0 {\n rect box 0,0 10x10 colour=red\n}\n';
|
|
111
|
+
const [diagnostic, ...rest] = diagramDiagnostics(source);
|
|
112
|
+
expect(rest).toEqual([]);
|
|
113
|
+
expect(diagnostic.message).toContain('colour');
|
|
114
|
+
expect(source.slice(diagnostic.from, diagnostic.to)).toEqual('colour=red');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('a layout error points at the object that caused it, not the top of the file', ({ expect }) => {
|
|
118
|
+
// Two boxes on the same spot — `node-overlap`, which `Diagnostics` reports against both refs.
|
|
119
|
+
const source = printCommands([
|
|
120
|
+
{ op: 'upsert-object', object: { id: 'A', origin: { x: 0, y: 0 }, elements: [box('a')] } },
|
|
121
|
+
{ op: 'upsert-object', object: { id: 'B', origin: { x: 0, y: 0 }, elements: [box('b')] } },
|
|
122
|
+
]);
|
|
123
|
+
const overlaps = diagramDiagnostics(source, { layout: true }).filter(({ source: code }) => code === 'node-overlap');
|
|
124
|
+
expect(overlaps.length).toBeGreaterThan(0);
|
|
125
|
+
expect(overlaps[0].from).toBeGreaterThan(0);
|
|
126
|
+
expect(source.slice(overlaps[0].from, overlaps[0].to)).toContain('rect');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('layout is not analysed while the document has a syntax error', ({ expect }) => {
|
|
130
|
+
const diagnostics = diagramDiagnostics('object A @ 0,0 {\n rect box 0,0\n}\n', { layout: true });
|
|
131
|
+
expect(diagnostics.every(({ source }) => source === undefined)).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const box = (id: string) => ({ kind: 'rect', id, x: 0, y: 0, w: 100, h: 50 }) as const;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { HighlightStyle } from '@codemirror/language';
|
|
6
|
+
import { tags } from '@lezer/highlight';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Fills the gaps the editor's own highlight style leaves. The grammar emits standard
|
|
10
|
+
* `@lezer/highlight` tags, so most of a document is coloured by whatever style the theme installs
|
|
11
|
+
* and inherits its palette — but `vscodeLightStyle`/`vscodeDarkStyle` define neither
|
|
12
|
+
* `attributeValue` nor `null`, which in this language are the enum values (`grey`, `dashed`,
|
|
13
|
+
* `triangle`) and the unbound arrow end. Those are the most scannable part of a diagram, so
|
|
14
|
+
* leaving them uncoloured is the one thing worth overriding.
|
|
15
|
+
*
|
|
16
|
+
* Classes are semantic theme tokens rather than hues, as in `mermaidHighlightStyle`, so light and
|
|
17
|
+
* dark follow the palette.
|
|
18
|
+
*/
|
|
19
|
+
export const diagramHighlightStyle = () =>
|
|
20
|
+
HighlightStyle.define([
|
|
21
|
+
{ tag: tags.attributeValue, class: 'text-accent' },
|
|
22
|
+
{ tag: tags.null, class: 'text-fg-subtle' },
|
|
23
|
+
]);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
export { diagram, diagramBlockDescription } from './language.ts';
|
|
6
|
+
export { type LintOptions, diagramDiagnostics, diagramLint } from './lint.ts';
|
|
7
|
+
export { diagramComplete } from './complete.ts';
|
|
8
|
+
export { diagramHighlightStyle } from './highlight.ts';
|
|
9
|
+
export { diagramLanguage } from './syntax.ts';
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { LanguageDescription, LanguageSupport, syntaxHighlighting } from '@codemirror/language';
|
|
6
|
+
import { type Extension } from '@codemirror/state';
|
|
7
|
+
|
|
8
|
+
import { diagramComplete } from './complete.ts';
|
|
9
|
+
import { diagramHighlightStyle } from './highlight.ts';
|
|
10
|
+
import { type LintOptions, diagramLint } from './lint.ts';
|
|
11
|
+
import { diagramLanguage } from './syntax.ts';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The fence dispatch point. Pass this alongside deus's `mdlBlockDescription` in
|
|
15
|
+
* `createMarkdownExtensions({ codeLanguages: [...] })` and a ```diagram block inside a `.mdl`
|
|
16
|
+
* document parses with the diagram grammar — a sibling of MDL's, not a nesting inside it. See
|
|
17
|
+
* `docs/DSL.md`.
|
|
18
|
+
*/
|
|
19
|
+
export const diagramBlockDescription = LanguageDescription.of({
|
|
20
|
+
name: 'diagram',
|
|
21
|
+
alias: ['dxd'],
|
|
22
|
+
support: new LanguageSupport(diagramLanguage),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** The whole editing surface for a standalone DSL buffer: mode, highlighting, completion, lint. */
|
|
26
|
+
export const diagram = (options: LintOptions = {}): Extension => [
|
|
27
|
+
new LanguageSupport(diagramLanguage),
|
|
28
|
+
syntaxHighlighting(diagramHighlightStyle()),
|
|
29
|
+
diagramComplete,
|
|
30
|
+
diagramLint(options),
|
|
31
|
+
];
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Diagnostic, linter } from '@codemirror/lint';
|
|
6
|
+
import { type Extension } from '@codemirror/state';
|
|
7
|
+
import { type EditorView } from '@codemirror/view';
|
|
8
|
+
|
|
9
|
+
import * as Diagnostics from '../diagnostics.ts';
|
|
10
|
+
import { parse, read, toScene } from '../dsl/parse.ts';
|
|
11
|
+
|
|
12
|
+
export type LintOptions = {
|
|
13
|
+
/**
|
|
14
|
+
* Also report the layout diagnostics — overlaps, a connector through a node, a label that does
|
|
15
|
+
* not fit. Off by default because a document being typed is briefly nonsense; the bench and the
|
|
16
|
+
* agent loop both want it on.
|
|
17
|
+
*/
|
|
18
|
+
layout?: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What is wrong with a document: everything `parse` found, and optionally everything
|
|
23
|
+
* `Diagnostics.analyze` finds in the scene it produced, each carrying the range of the statement
|
|
24
|
+
* or element that produced it — a layout error is only actionable when it points at the box it is
|
|
25
|
+
* about. Separate from the CodeMirror wrapper so it needs no editor, which is also how the
|
|
26
|
+
* agent-facing path reports the same problems.
|
|
27
|
+
*/
|
|
28
|
+
export const diagramDiagnostics = (text: string, { layout = false }: LintOptions = {}): Diagnostic[] => {
|
|
29
|
+
// Laying out semantic statements is a search; without the layout report only the reading is needed.
|
|
30
|
+
const { commands, problems, ranges } = layout ? parse(text) : read(text);
|
|
31
|
+
const clamp = (value: number) => Math.max(0, Math.min(value, text.length));
|
|
32
|
+
|
|
33
|
+
const diagnostics: Diagnostic[] = problems.map(({ severity, message, from, to }) => ({
|
|
34
|
+
severity,
|
|
35
|
+
message,
|
|
36
|
+
from: clamp(from),
|
|
37
|
+
to: clamp(to),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
// A document with a syntax error has a scene that is missing pieces, so its layout report would
|
|
41
|
+
// be noise on top of an error the author is already fixing.
|
|
42
|
+
if (layout && !problems.some(({ severity }) => severity === 'error')) {
|
|
43
|
+
for (const diagnostic of Diagnostics.analyze(toScene(commands).objects).diagnostics) {
|
|
44
|
+
const range = diagnostic.refs.flatMap((ref) => ranges.get(ref) ?? []).at(0);
|
|
45
|
+
diagnostics.push({
|
|
46
|
+
severity: diagnostic.severity,
|
|
47
|
+
source: diagnostic.code,
|
|
48
|
+
message: diagnostic.message,
|
|
49
|
+
from: clamp(range?.from ?? 0),
|
|
50
|
+
to: clamp(range?.to ?? Math.min(1, text.length)),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return diagnostics.sort((left, right) => left.from - right.from);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** The CodeMirror linter over {@link diagramDiagnostics}. */
|
|
59
|
+
export const diagramLint = (options: LintOptions = {}): Extension =>
|
|
60
|
+
linter((view: EditorView): Diagnostic[] => diagramDiagnostics(view.state.doc.toString(), options));
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { LRLanguage } from '@codemirror/language';
|
|
6
|
+
import { styleTags, tags } from '@lezer/highlight';
|
|
7
|
+
|
|
8
|
+
import { parser } from '../dsl/gen/diagram.ts';
|
|
9
|
+
|
|
10
|
+
//
|
|
11
|
+
// Unlike deus's MDL mode — which keeps highlighting on a regex ViewPlugin so prose inside a block
|
|
12
|
+
// cannot trip LR error recovery — the diagram grammar covers its whole input, so highlighting comes
|
|
13
|
+
// off the tree. The tags are the standard ones, so most of a document inherits the editor's own
|
|
14
|
+
// palette; `highlight.ts` covers only the two the theme leaves undefined.
|
|
15
|
+
//
|
|
16
|
+
|
|
17
|
+
export const diagramLanguage = LRLanguage.define({
|
|
18
|
+
name: 'diagram',
|
|
19
|
+
parser: parser.configure({
|
|
20
|
+
props: [
|
|
21
|
+
styleTags({
|
|
22
|
+
'object elements move remove diagram group node edge': tags.definitionKeyword,
|
|
23
|
+
'cell via bus compact': tags.keyword,
|
|
24
|
+
// Relation words and sides are plain names the reader checks, so they are tagged by position.
|
|
25
|
+
'RelationKind Side': tags.keyword,
|
|
26
|
+
'Soft': tags.modifier,
|
|
27
|
+
'rect ellipse diamond triangle circle line curve arc text arrow portal': tags.keyword,
|
|
28
|
+
// A quoted id is still an id, not a string.
|
|
29
|
+
'Id/String': tags.variableName,
|
|
30
|
+
'Id': tags.variableName,
|
|
31
|
+
'Ref': tags.propertyName,
|
|
32
|
+
'Label': tags.string,
|
|
33
|
+
'String': tags.string,
|
|
34
|
+
'Number': tags.number,
|
|
35
|
+
'Size': tags.number,
|
|
36
|
+
'Ratio': tags.number,
|
|
37
|
+
'AttrName': tags.attributeName,
|
|
38
|
+
// On the leaf, not on `AttrValue`/`Unbound`: a named child with no tag of its own does not
|
|
39
|
+
// inherit its parent's, so tagging the wrapper colours nothing. A numeric or quoted value
|
|
40
|
+
// keeps its own number/string tag; this is the bare enum word (`grey`, `dashed`).
|
|
41
|
+
'Word': tags.attributeValue,
|
|
42
|
+
// `head=triangle`, `head=arrow`, `tail=circle`: schema literals that are also element-kind
|
|
43
|
+
// keywords, so without the path they would colour as keywords while every other enum value
|
|
44
|
+
// colours as a value. Listed in full, like the grammar's own `Word` alternation, so a
|
|
45
|
+
// literal that starts colliding later is already covered.
|
|
46
|
+
'Word/object Word/elements Word/move Word/remove Word/rect Word/ellipse Word/diamond Word/triangle Word/circle Word/line Word/curve Word/arc Word/text Word/arrow Word/portal Word/diagram Word/group Word/node Word/edge Word/cell Word/via Word/bus Word/compact':
|
|
47
|
+
tags.attributeValue,
|
|
48
|
+
'_': tags.null,
|
|
49
|
+
'Comment': tags.lineComment,
|
|
50
|
+
'-> <-> -- @ .. : | ~': tags.operator,
|
|
51
|
+
'( )': tags.paren,
|
|
52
|
+
'=': tags.definitionOperator,
|
|
53
|
+
',': tags.separator,
|
|
54
|
+
'{ }': tags.brace,
|
|
55
|
+
}),
|
|
56
|
+
],
|
|
57
|
+
}),
|
|
58
|
+
languageData: {
|
|
59
|
+
commentTokens: { line: '#' },
|
|
60
|
+
closeBrackets: { brackets: ['{', '"'] },
|
|
61
|
+
},
|
|
62
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -4,12 +4,25 @@
|
|
|
4
4
|
|
|
5
5
|
export * from './content.ts';
|
|
6
6
|
export * from './dialect.ts';
|
|
7
|
+
export * as Aesthetics from './aesthetics.ts';
|
|
8
|
+
export * as Appeal from './appeal.ts';
|
|
9
|
+
export * as Architecture from './architecture.ts';
|
|
7
10
|
export * as Diagnostics from './diagnostics.ts';
|
|
11
|
+
export * as Dsl from './dsl/index.ts';
|
|
12
|
+
export * as DxSvg from './dx-svg.ts';
|
|
8
13
|
export * as Layout from './layout.ts';
|
|
9
14
|
export * as Mermaid from './mermaid.ts';
|
|
10
15
|
export * as MermaidEngine from './mermaid-engine.ts';
|
|
16
|
+
export * as Nudge from './nudge.ts';
|
|
11
17
|
export * as Objective from './objective.ts';
|
|
18
|
+
export * as Ports from './ports.ts';
|
|
19
|
+
export * as Rules from './rules.ts';
|
|
12
20
|
export * as Scene from './scene.ts';
|
|
21
|
+
export * as Semantic from './semantic.ts';
|
|
22
|
+
export * as SemanticEngine from './semantic-engine.ts';
|
|
23
|
+
export * as SemanticPlace from './semantic-place.ts';
|
|
24
|
+
export * as SemanticRoute from './semantic-route.ts';
|
|
25
|
+
export * as Score from './score.ts';
|
|
13
26
|
export * from './svg-handler.ts';
|
|
14
27
|
export * as Ui from './ui.ts';
|
|
15
28
|
export * as Uml from './uml.ts';
|
|
@@ -17,3 +30,4 @@ export * as UmlEngine from './uml-engine.ts';
|
|
|
17
30
|
export * as UmlGrid from './uml-grid.ts';
|
|
18
31
|
export * as UmlRules from './uml-rules.ts';
|
|
19
32
|
export * as UmlSearch from './uml-search.ts';
|
|
33
|
+
export * as View from './view.ts';
|
|
@@ -119,7 +119,7 @@ describe('mermaid-engine', { timeout: 120_000 }, () => {
|
|
|
119
119
|
expect(edges.filter(({ id }) => /-stub-\d$/.test(id))).toHaveLength(2);
|
|
120
120
|
const labelled = edges.filter((element) => element.kind === 'text' && element.text === 'via mixin');
|
|
121
121
|
expect(labelled).toHaveLength(1);
|
|
122
|
-
expect(edges.filter((element) => element.kind === 'arrow' && element.
|
|
122
|
+
expect(edges.filter((element) => element.kind === 'arrow' && element.relation === 'inheritance')).toHaveLength(2);
|
|
123
123
|
});
|
|
124
124
|
|
|
125
125
|
test('toStandard rewrites the UML tokens into mermaid-legal labelled arrows', ({ expect }) => {
|
|
@@ -181,10 +181,10 @@ describe('mermaid-engine', { timeout: 120_000 }, () => {
|
|
|
181
181
|
.elements.flatMap((element) => (element.kind === 'arrow' ? [element] : []));
|
|
182
182
|
const byId = (prefix: string) => arrows.find(({ id }) => id.startsWith(prefix))!;
|
|
183
183
|
|
|
184
|
-
expect(byId('B-A')).toMatchObject({
|
|
185
|
-
expect(byId('X-Y')).toMatchObject({
|
|
186
|
-
expect(byId('P-Q')).toMatchObject({
|
|
187
|
-
expect(byId('R-S').
|
|
184
|
+
expect(byId('B-A')).toMatchObject({ relation: 'inheritance' });
|
|
185
|
+
expect(byId('X-Y')).toMatchObject({ relation: 'one-to-many' });
|
|
186
|
+
expect(byId('P-Q')).toMatchObject({ relation: 'aggregation' });
|
|
187
|
+
expect(byId('R-S').relation).toBeUndefined();
|
|
188
188
|
});
|
|
189
189
|
|
|
190
190
|
test('basic: no hard defects, every connector straight or one bend', async ({ expect }) => {
|
|
@@ -204,7 +204,7 @@ describe('mermaid-engine', { timeout: 120_000 }, () => {
|
|
|
204
204
|
const edges = objects.find(({ id }) => id === 'edges')!.elements;
|
|
205
205
|
expect(edges.find(({ id }) => id === 'A-bus')?.kind).toBe('line');
|
|
206
206
|
expect(edges.filter(({ id }) => id.endsWith('-stub-0') || id.endsWith('-stub-1'))).toHaveLength(2);
|
|
207
|
-
expect(edges.filter((element) => element.kind === 'arrow' && element.
|
|
207
|
+
expect(edges.filter((element) => element.kind === 'arrow' && element.relation === 'inheritance')).toHaveLength(1);
|
|
208
208
|
// Nothing else needs a second bend, and the three frames sit at equal gaps.
|
|
209
209
|
expect(report.metrics.frameGapSpread).toBe(0);
|
|
210
210
|
|