@archcode-io/engine 0.2.0-preview.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/CHANGELOG.md +31 -0
- package/LICENSE +202 -0
- package/README.md +88 -0
- package/dist/src/capacity.d.ts +67 -0
- package/dist/src/capacity.js +227 -0
- package/dist/src/check.d.ts +21 -0
- package/dist/src/check.js +192 -0
- package/dist/src/compiled.d.ts +61 -0
- package/dist/src/compiled.js +91 -0
- package/dist/src/cst.d.ts +76 -0
- package/dist/src/cst.js +1 -0
- package/dist/src/diagnostics.d.ts +9 -0
- package/dist/src/diagnostics.js +1 -0
- package/dist/src/edit.d.ts +48 -0
- package/dist/src/edit.js +442 -0
- package/dist/src/index.d.ts +25 -0
- package/dist/src/index.js +24 -0
- package/dist/src/layout/balance.d.ts +34 -0
- package/dist/src/layout/balance.js +327 -0
- package/dist/src/layout/elk.d.ts +64 -0
- package/dist/src/layout/elk.js +267 -0
- package/dist/src/layout/host.d.ts +23 -0
- package/dist/src/layout/host.js +23 -0
- package/dist/src/layout/label.d.ts +49 -0
- package/dist/src/layout/label.js +113 -0
- package/dist/src/layout/measure.d.ts +11 -0
- package/dist/src/layout/measure.js +27 -0
- package/dist/src/layout/ortho.d.ts +54 -0
- package/dist/src/layout/ortho.js +206 -0
- package/dist/src/layout/route.d.ts +57 -0
- package/dist/src/layout/route.js +230 -0
- package/dist/src/lens.d.ts +83 -0
- package/dist/src/lens.js +377 -0
- package/dist/src/lexer.d.ts +7 -0
- package/dist/src/lexer.js +135 -0
- package/dist/src/model.d.ts +63 -0
- package/dist/src/model.js +114 -0
- package/dist/src/parser.d.ts +7 -0
- package/dist/src/parser.js +305 -0
- package/dist/src/render/svg.d.ts +56 -0
- package/dist/src/render/svg.js +289 -0
- package/dist/src/serialize.d.ts +10 -0
- package/dist/src/serialize.js +12 -0
- package/dist/src/tokens.d.ts +26 -0
- package/dist/src/tokens.js +15 -0
- package/dist/src/vocab.d.ts +38 -0
- package/dist/src/vocab.js +58 -0
- package/package.json +61 -0
package/dist/src/edit.js
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { parse } from './parser.js';
|
|
2
|
+
import { tokenText } from './tokens.js';
|
|
3
|
+
const span = (tokens) => ({
|
|
4
|
+
start: tokens[0]?.line ?? 1,
|
|
5
|
+
end: tokens[tokens.length - 1]?.line ?? tokens[0]?.line ?? 1,
|
|
6
|
+
});
|
|
7
|
+
function each(nodes, fn) {
|
|
8
|
+
for (const n of nodes) {
|
|
9
|
+
fn(n);
|
|
10
|
+
const body = n.body;
|
|
11
|
+
if (body)
|
|
12
|
+
each(body, fn);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const findDecl = (doc, id) => {
|
|
16
|
+
let hit;
|
|
17
|
+
each(doc.body, n => { if (!hit && n.type === 'decl' && n.id === id)
|
|
18
|
+
hit = n; });
|
|
19
|
+
return hit;
|
|
20
|
+
};
|
|
21
|
+
/** Where a new top-level statement should go: after the model, before the view. */
|
|
22
|
+
function insertPoint(src) {
|
|
23
|
+
const lines = src.split('\n');
|
|
24
|
+
const viewAt = lines.findIndex(l => /^\s*(view|board)\b/.test(l));
|
|
25
|
+
if (viewAt < 0)
|
|
26
|
+
return lines.length;
|
|
27
|
+
let i = viewAt;
|
|
28
|
+
while (i > 0 && lines[i - 1].trim() === '')
|
|
29
|
+
i--;
|
|
30
|
+
return i;
|
|
31
|
+
}
|
|
32
|
+
const splice = (src, at, remove, insert) => {
|
|
33
|
+
const lines = src.split('\n');
|
|
34
|
+
lines.splice(at, remove, ...insert);
|
|
35
|
+
return lines.join('\n');
|
|
36
|
+
};
|
|
37
|
+
// ── objects ───────────────────────────────────────────────────────────────
|
|
38
|
+
/**
|
|
39
|
+
* Append one statement written in the notation itself — a declaration or a
|
|
40
|
+
* relation typed into the canvas terminal. The line goes in verbatim, before
|
|
41
|
+
* the first `view`/`board` block, so what the person typed is what the file
|
|
42
|
+
* says; nothing is normalised on the way in.
|
|
43
|
+
*/
|
|
44
|
+
export function insertStatement(src, line) {
|
|
45
|
+
const at = insertPoint(src);
|
|
46
|
+
const before = src.split('\n')[at - 1];
|
|
47
|
+
return splice(src, at, 0, [...(before && before.trim() ? [''] : []), line.trim()]);
|
|
48
|
+
}
|
|
49
|
+
export function addObject(src, o) {
|
|
50
|
+
const parts = [o.kind, o.id];
|
|
51
|
+
if (o.name)
|
|
52
|
+
parts.push(JSON.stringify(o.name));
|
|
53
|
+
for (const [k, v] of Object.entries(o.attrs ?? {}))
|
|
54
|
+
parts.push(k, /\s/.test(v) ? JSON.stringify(v) : v);
|
|
55
|
+
const at = insertPoint(src);
|
|
56
|
+
const before = src.split('\n')[at - 1];
|
|
57
|
+
return splice(src, at, 0, [...(before && before.trim() ? [''] : []), parts.join(' ')]);
|
|
58
|
+
}
|
|
59
|
+
export function deleteObject(src, id) {
|
|
60
|
+
const { doc } = parse(src);
|
|
61
|
+
const d = findDecl(doc, id);
|
|
62
|
+
let out = src;
|
|
63
|
+
if (d) {
|
|
64
|
+
const s = span(d.tokens);
|
|
65
|
+
out = splice(out, s.start - 1, s.end - s.start + 1, []);
|
|
66
|
+
}
|
|
67
|
+
// and every relation that mentions it — a dangling arrow is worse than none
|
|
68
|
+
for (;;) {
|
|
69
|
+
const { doc: d2 } = parse(out);
|
|
70
|
+
let hit;
|
|
71
|
+
each(d2.body, n => {
|
|
72
|
+
if (hit)
|
|
73
|
+
return;
|
|
74
|
+
if (n.type === 'iface') {
|
|
75
|
+
if (n.subject === id)
|
|
76
|
+
hit = n;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (n.type !== 'rel')
|
|
80
|
+
return;
|
|
81
|
+
const r = n;
|
|
82
|
+
if (r.subject === id || r.object === id || (r.via ?? []).includes(id))
|
|
83
|
+
hit = r;
|
|
84
|
+
});
|
|
85
|
+
if (!hit)
|
|
86
|
+
break;
|
|
87
|
+
const s = span(hit.tokens);
|
|
88
|
+
out = splice(out, s.start - 1, s.end - s.start + 1, []);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
/** Rename everywhere: the declaration and every reference to it. */
|
|
93
|
+
export function renameObject(src, oldId, newId) {
|
|
94
|
+
const re = new RegExp(`(^|[^\\w.-])${oldId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-])`, 'g');
|
|
95
|
+
return src.split('\n').map(line => {
|
|
96
|
+
const ci = line.indexOf('#');
|
|
97
|
+
const [code, cm] = ci >= 0 ? [line.slice(0, ci), line.slice(ci)] : [line, ''];
|
|
98
|
+
return code.replace(re, (_, p) => p + newId) + cm;
|
|
99
|
+
}).join('\n');
|
|
100
|
+
}
|
|
101
|
+
export function setDisplayName(src, id, name) {
|
|
102
|
+
const { doc } = parse(src);
|
|
103
|
+
const d = findDecl(doc, id);
|
|
104
|
+
if (!d)
|
|
105
|
+
return src;
|
|
106
|
+
const lines = src.split('\n');
|
|
107
|
+
const quoted = JSON.stringify(name);
|
|
108
|
+
// The identifier's position comes from its token, never from searching the
|
|
109
|
+
// line: `datastore db` is fine, but `datastore datastore` would find the
|
|
110
|
+
// KIND first and insert the name in front of the identifier.
|
|
111
|
+
const idTok = d.tokens.find(t => t.kind === 'word' && t.text === id && t !== d.tokens[0]);
|
|
112
|
+
const nameTok = d.tokens.find(t => t.kind === 'string');
|
|
113
|
+
const line = (nameTok ?? idTok ?? d.tokens[0]).line;
|
|
114
|
+
const cur = lines[line - 1];
|
|
115
|
+
if (nameTok) {
|
|
116
|
+
const from = nameTok.col - 1;
|
|
117
|
+
return splice(src, line - 1, 1, [cur.slice(0, from) + quoted + cur.slice(from + nameTok.text.length)]);
|
|
118
|
+
}
|
|
119
|
+
if (!name)
|
|
120
|
+
return src;
|
|
121
|
+
const at = idTok ? idTok.col - 1 + idTok.text.length : cur.length;
|
|
122
|
+
return splice(src, line - 1, 1, [cur.slice(0, at) + ' ' + quoted + cur.slice(at)]);
|
|
123
|
+
}
|
|
124
|
+
export function setAttribute(src, id, key, value) {
|
|
125
|
+
const { doc } = parse(src);
|
|
126
|
+
const d = findDecl(doc, id);
|
|
127
|
+
if (!d)
|
|
128
|
+
return src;
|
|
129
|
+
const lines = src.split('\n');
|
|
130
|
+
const declLine = span(d.tokens).start;
|
|
131
|
+
// A list (`antivirus, alloy`) stays a list; anything else with a space is one quoted string.
|
|
132
|
+
const isList = /^[^"\s,]+(\s*,\s*[^"\s,]+)+$/.test(value ?? '');
|
|
133
|
+
const token = isList ? value.split(/\s*,\s*/).join(', ') : /\s/.test(value ?? '') ? JSON.stringify(value) : value;
|
|
134
|
+
const VAL = '("[^"]*"|[^\\s,]+(?:\\s*,\\s*[^\\s,]+)*)'; // a quoted string, a word, or a comma list
|
|
135
|
+
const inline = d.inline.find(a => a.key === key);
|
|
136
|
+
const inBody = (d.body ?? []).find(n => n.type === 'attr' && n.key === key);
|
|
137
|
+
const target = inline ?? inBody;
|
|
138
|
+
if (target) {
|
|
139
|
+
const at = span(target.tokens).start;
|
|
140
|
+
const cur = lines[at - 1];
|
|
141
|
+
if (value === null) {
|
|
142
|
+
const stripped = cur.replace(new RegExp(`\\s*\\b${key}\\b\\s+${VAL}`), '');
|
|
143
|
+
return splice(src, at - 1, 1, stripped.trim() ? [stripped] : []);
|
|
144
|
+
}
|
|
145
|
+
return splice(src, at - 1, 1, [cur.replace(new RegExp(`(\\b${key}\\b\\s+)${VAL}`), `$1${token}`)]);
|
|
146
|
+
}
|
|
147
|
+
if (value === null)
|
|
148
|
+
return src;
|
|
149
|
+
if (d.body) {
|
|
150
|
+
const ds = span(d.tokens);
|
|
151
|
+
if (ds.end === ds.start) // one-line body: `{ transit }` → `{ transit key value }`
|
|
152
|
+
return splice(src, declLine - 1, 1, [lines[declLine - 1].replace(/\s*\}\s*$/, ` ${key} ${token} }`)]);
|
|
153
|
+
const indent = lines[declLine - 1].match(/^[ \t]*/)[0] + ' ';
|
|
154
|
+
return splice(src, declLine, 0, [indent + key + ' ' + token]);
|
|
155
|
+
}
|
|
156
|
+
return splice(src, declLine - 1, 1, [lines[declLine - 1].replace(/\s*$/, '') + ` ${key} ${token}`]);
|
|
157
|
+
}
|
|
158
|
+
// ── relations ─────────────────────────────────────────────────────────────
|
|
159
|
+
export function addRelation(src, from, verb, to, over) {
|
|
160
|
+
const line = [from, verb, to, ...(over ? ['over', /\s/.test(over) ? JSON.stringify(over) : over] : [])].join(' ');
|
|
161
|
+
const at = insertPoint(src);
|
|
162
|
+
const before = src.split('\n')[at - 1];
|
|
163
|
+
return splice(src, at, 0, [...(before && before.trim() ? [''] : []), line]);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Move one end of a relation to another object — the text edit behind
|
|
167
|
+
* dropping an arrow's end on a different card. Only the token that names the
|
|
168
|
+
* end changes; `over`, `via`, the label and the body stay as written. Matching
|
|
169
|
+
* is by local id at either end, as `deleteRelation` does.
|
|
170
|
+
*/
|
|
171
|
+
export function retargetRelation(src, from, verb, to, end, newId) {
|
|
172
|
+
const { doc } = parse(src);
|
|
173
|
+
let hit;
|
|
174
|
+
const walk = (nodes, parent) => {
|
|
175
|
+
for (const n of nodes) {
|
|
176
|
+
if (hit)
|
|
177
|
+
return;
|
|
178
|
+
if (n.type === 'decl') {
|
|
179
|
+
walk(n.body ?? [], n.id);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (n.type !== 'rel')
|
|
183
|
+
continue;
|
|
184
|
+
const r = n;
|
|
185
|
+
const f = (r.subject ?? parent ?? '').split('.').pop(), t = r.object.split('.').pop();
|
|
186
|
+
if (f === from && t === to && (r.canonicalVerb === verb || r.verb === verb))
|
|
187
|
+
hit = r;
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
walk(doc.body, undefined);
|
|
191
|
+
if (!hit)
|
|
192
|
+
return src;
|
|
193
|
+
// the end's token: the subject is the first word of the statement, the object follows the verb
|
|
194
|
+
const words = hit.tokens.filter(t => t.kind === 'word' || t.kind === 'number' || t.kind === 'pointer');
|
|
195
|
+
const verbIdx = words.findIndex(t => t.text === hit.verb);
|
|
196
|
+
const tok = end === 'from' ? (hit.subject ? words[0] : undefined) : words[verbIdx + 1];
|
|
197
|
+
if (!tok) {
|
|
198
|
+
// an implicit subject (`calls x` inside a body) cannot be retargeted in place: write it out
|
|
199
|
+
if (end === 'from') {
|
|
200
|
+
const s = span(hit.tokens);
|
|
201
|
+
const lines = src.split('\n');
|
|
202
|
+
const line = lines[s.start - 1];
|
|
203
|
+
const rest = tokenText(hit.tokens).trim();
|
|
204
|
+
return splice(splice(src, s.start - 1, s.end - s.start + 1, []), s.start - 1, 0, [line.match(/^\s*/)[0] + `${newId} ${rest}`]);
|
|
205
|
+
}
|
|
206
|
+
return src;
|
|
207
|
+
}
|
|
208
|
+
const lines = src.split('\n');
|
|
209
|
+
const line = lines[tok.line - 1];
|
|
210
|
+
const col = tok.col - 1;
|
|
211
|
+
return splice(src, tok.line - 1, 1, [line.slice(0, col) + newId + line.slice(col + tok.text.length)]);
|
|
212
|
+
}
|
|
213
|
+
/** The relation statement whose ends and verb match, by local id at either end. */
|
|
214
|
+
function findRelation(doc, from, verb, to) {
|
|
215
|
+
let hit;
|
|
216
|
+
const walk = (nodes, parent) => {
|
|
217
|
+
for (const n of nodes) {
|
|
218
|
+
if (hit)
|
|
219
|
+
return;
|
|
220
|
+
if (n.type === 'decl') {
|
|
221
|
+
walk(n.body ?? [], n.id);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (n.type !== 'rel')
|
|
225
|
+
continue;
|
|
226
|
+
const r = n;
|
|
227
|
+
const f = (r.subject ?? parent ?? '').split('.').pop(), t = r.object.split('.').pop();
|
|
228
|
+
if (f === from && t === to && (r.canonicalVerb === verb || r.verb === verb))
|
|
229
|
+
hit = r;
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
walk(doc.body, undefined);
|
|
233
|
+
return hit;
|
|
234
|
+
}
|
|
235
|
+
/** Replace one token's text in place. */
|
|
236
|
+
const swapToken = (src, tok, text) => {
|
|
237
|
+
const lines = src.split('\n');
|
|
238
|
+
const line = lines[tok.line - 1], col = tok.col - 1;
|
|
239
|
+
return splice(src, tok.line - 1, 1, [line.slice(0, col) + text + line.slice(col + tok.text.length)]);
|
|
240
|
+
};
|
|
241
|
+
/** `a calls b` → `a uses b`: only the verb changes, everything else stays as written. */
|
|
242
|
+
export function setRelationVerb(src, from, verb, to, newVerb) {
|
|
243
|
+
const hit = findRelation(parse(src).doc, from, verb, to);
|
|
244
|
+
if (!hit)
|
|
245
|
+
return src;
|
|
246
|
+
const tok = hit.tokens.find(t => t.kind === 'word' && t.text === hit.verb);
|
|
247
|
+
return tok ? swapToken(src, tok, newVerb) : src;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Set, change or drop (`value` null) an inline fact of a relation: `over`,
|
|
251
|
+
* `via`, `port`, `spec`, `as`, or the quoted `label`. A fact that is there is
|
|
252
|
+
* rewritten where it stands; a new one goes at the end of the statement's
|
|
253
|
+
* first line, before the body. `via` takes a comma-separated list.
|
|
254
|
+
*/
|
|
255
|
+
export function setRelationAttr(src, from, verb, to, key, value) {
|
|
256
|
+
const hit = findRelation(parse(src).doc, from, verb, to);
|
|
257
|
+
if (!hit)
|
|
258
|
+
return src;
|
|
259
|
+
const first = hit.tokens[0].line;
|
|
260
|
+
const onLine = hit.tokens.filter(t => t.line === first && t.kind !== 'newline' && t.kind !== 'lbrace');
|
|
261
|
+
const quote = (v) => (/[\s"#{}]/.test(v) || !v ? JSON.stringify(v) : v);
|
|
262
|
+
let start = -1, end = -1; // token range of the existing fact, inclusive
|
|
263
|
+
if (key === 'label') {
|
|
264
|
+
const i = onLine.findIndex(t => t.kind === 'string');
|
|
265
|
+
if (i >= 0) {
|
|
266
|
+
start = i;
|
|
267
|
+
end = i;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
const i = onLine.findIndex(t => t.kind === 'word' && t.text === key);
|
|
272
|
+
if (i >= 0) {
|
|
273
|
+
start = i;
|
|
274
|
+
end = i;
|
|
275
|
+
// the values: one token, or a comma-separated list
|
|
276
|
+
if (i + 1 < onLine.length && !RELATION_MODIFIER_WORDS.has(onLine[i + 1].text))
|
|
277
|
+
end = i + 1;
|
|
278
|
+
while (end + 2 < onLine.length && onLine[end + 1].kind === 'comma')
|
|
279
|
+
end += 2;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const text = value === null ? '' : key === 'label' ? JSON.stringify(value) : `${key} ${key === 'via' ? value.split(/\s*,\s*/).map(quote).join(', ') : quote(value)}`;
|
|
283
|
+
const lines = src.split('\n');
|
|
284
|
+
const line = lines[first - 1];
|
|
285
|
+
if (start >= 0) {
|
|
286
|
+
const a = onLine[start].col - 1, b = onLine[end].col - 1 + onLine[end].text.length;
|
|
287
|
+
const before = line.slice(0, a), after = line.slice(b);
|
|
288
|
+
return splice(src, first - 1, 1, [text ? before + text + after : (before.replace(/\s+$/, '') + (after.trim() ? ' ' + after.trimStart() : after.trimEnd()))]);
|
|
289
|
+
}
|
|
290
|
+
if (!text)
|
|
291
|
+
return src;
|
|
292
|
+
// append before the body brace or the comment, whichever comes first
|
|
293
|
+
const m = /^(.*?)(\s*\{.*|\s*#.*)?$/.exec(line);
|
|
294
|
+
const head = m[1].replace(/\s+$/, ''), tail = m[2] ?? '';
|
|
295
|
+
return splice(src, first - 1, 1, [`${head} ${text}${tail}`]);
|
|
296
|
+
}
|
|
297
|
+
const RELATION_MODIFIER_WORDS = new Set(['over', 'via', 'spec', 'as', 'port']);
|
|
298
|
+
export function deleteRelation(src, from, verb, to) {
|
|
299
|
+
const { doc } = parse(src);
|
|
300
|
+
let hit;
|
|
301
|
+
each(doc.body, n => {
|
|
302
|
+
if (hit || n.type !== 'rel')
|
|
303
|
+
return;
|
|
304
|
+
const r = n;
|
|
305
|
+
if ((r.subject ?? from) === from && r.object === to && (r.canonicalVerb === verb || r.verb === verb))
|
|
306
|
+
hit = r;
|
|
307
|
+
});
|
|
308
|
+
if (!hit)
|
|
309
|
+
return src;
|
|
310
|
+
const s = span(hit.tokens);
|
|
311
|
+
return splice(src, s.start - 1, s.end - s.start + 1, []);
|
|
312
|
+
}
|
|
313
|
+
/** Lift a declaration back out to the top level. */
|
|
314
|
+
/** Leading whitespace shared by every non-blank line — the block's own indent. */
|
|
315
|
+
const commonIndent = (lines) => Math.min(...lines.filter(l => l.trim()).map(l => l.match(/^[ \t]*/)[0].length), 0x7fffffff) || 0;
|
|
316
|
+
const reindent = (lines, to) => {
|
|
317
|
+
const drop = commonIndent(lines);
|
|
318
|
+
return lines.map(l => (l.trim() ? to + l.slice(drop) : ''));
|
|
319
|
+
};
|
|
320
|
+
/**
|
|
321
|
+
* Move a declaration one level up: a component leaves its container for the
|
|
322
|
+
* system, a container leaves its system for the top level. The text keeps its
|
|
323
|
+
* inner shape — only the common indent changes.
|
|
324
|
+
*/
|
|
325
|
+
function parentDeclOf(doc, d) {
|
|
326
|
+
const walk = (nodes, parent) => {
|
|
327
|
+
for (const n of nodes) {
|
|
328
|
+
if (n === d)
|
|
329
|
+
return parent;
|
|
330
|
+
if (n.type === 'decl' && n.body) {
|
|
331
|
+
const r = walk(n.body, n);
|
|
332
|
+
if (r)
|
|
333
|
+
return r;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return undefined;
|
|
337
|
+
};
|
|
338
|
+
return walk(doc.body);
|
|
339
|
+
}
|
|
340
|
+
export function moveOut(src, id) {
|
|
341
|
+
const { doc } = parse(src);
|
|
342
|
+
const d = findDecl(doc, id);
|
|
343
|
+
if (!d)
|
|
344
|
+
return src;
|
|
345
|
+
const parent = parentDeclOf(doc, d);
|
|
346
|
+
const grand = parent ? parentDeclOf(doc, parent) : undefined;
|
|
347
|
+
if (grand)
|
|
348
|
+
return moveInto(src, id, grand.id);
|
|
349
|
+
const ds = span(d.tokens);
|
|
350
|
+
const text = reindent(src.split('\n').slice(ds.start - 1, ds.end), '');
|
|
351
|
+
let out = splice(src, ds.start - 1, ds.end - ds.start + 1, []);
|
|
352
|
+
// An emptied block would leave `system shop { }` behind — tidy it away.
|
|
353
|
+
out = out.replace(/^([ \t]*\w+[^\n{]*)\{\s*\n\s*\}[ \t]*$/gm, (_m, head) => head.replace(/\s+$/, ''));
|
|
354
|
+
const at = insertPoint(out);
|
|
355
|
+
const before = out.split('\n')[at - 1];
|
|
356
|
+
return splice(out, at, 0, [...(before && before.trim() ? [''] : []), ...text]);
|
|
357
|
+
}
|
|
358
|
+
/** Move a declaration inside a system's block, creating the block if needed. */
|
|
359
|
+
export function moveInto(src, id, parentId) {
|
|
360
|
+
const { doc } = parse(src);
|
|
361
|
+
const d = findDecl(doc, id), target = findDecl(doc, parentId);
|
|
362
|
+
if (!d || !target || d === target)
|
|
363
|
+
return src;
|
|
364
|
+
const ds = span(d.tokens);
|
|
365
|
+
const raw = src.split('\n').slice(ds.start - 1, ds.end);
|
|
366
|
+
let out = splice(src, ds.start - 1, ds.end - ds.start + 1, []);
|
|
367
|
+
out = out.replace(/^([ \t]*\w+[^\n{]*)\{\s*\n\s*\}[ \t]*$/gm, (_m, head) => head.replace(/\s+$/, ''));
|
|
368
|
+
const { doc: d2 } = parse(out);
|
|
369
|
+
const t2 = findDecl(d2, parentId);
|
|
370
|
+
if (!t2)
|
|
371
|
+
return src;
|
|
372
|
+
const ts = span(t2.tokens);
|
|
373
|
+
const lines = out.split('\n');
|
|
374
|
+
const indent = lines[ts.start - 1].match(/^[ \t]*/)[0] + ' ';
|
|
375
|
+
const text = reindent(raw, indent);
|
|
376
|
+
const head = lines[ts.start - 1];
|
|
377
|
+
if (t2.body && ts.end === ts.start) {
|
|
378
|
+
// one-line body `{ region ru }` opens up to hold a member
|
|
379
|
+
const open = head.indexOf('{'), close = head.lastIndexOf('}');
|
|
380
|
+
const inner = head.slice(open + 1, close).trim();
|
|
381
|
+
return splice(out, ts.start - 1, 1, [head.slice(0, open).replace(/\s*$/, '') + ' {', ...(inner ? [indent + inner] : []), ...text, indent.slice(2) + '}']);
|
|
382
|
+
}
|
|
383
|
+
if (t2.body)
|
|
384
|
+
return splice(out, ts.start, 0, text);
|
|
385
|
+
return splice(out, ts.start - 1, 1, [head.replace(/\s*$/, '') + ' {', ...text, indent.slice(2) + '}']);
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Place a logical object on a host: `run <ref> [sizing]` goes into the host's
|
|
389
|
+
* body. A host without a body gets one; a one-line body opens up.
|
|
390
|
+
*/
|
|
391
|
+
export function addRun(src, hostId, ref, sizing = '') {
|
|
392
|
+
const { doc } = parse(src);
|
|
393
|
+
const host = findDecl(doc, hostId);
|
|
394
|
+
if (!host)
|
|
395
|
+
return src;
|
|
396
|
+
if ((host.body ?? []).some(n => n.type === 'run' && n.refs.includes(ref)))
|
|
397
|
+
return src;
|
|
398
|
+
const hs = span(host.tokens);
|
|
399
|
+
const lines = src.split('\n');
|
|
400
|
+
const head = lines[hs.start - 1];
|
|
401
|
+
const indent = head.match(/^[ \t]*/)[0] + ' ';
|
|
402
|
+
const line = indent + ['run', ref, sizing.trim()].filter(Boolean).join(' ');
|
|
403
|
+
if (host.body && hs.end === hs.start) {
|
|
404
|
+
const open = head.indexOf('{'), close = head.lastIndexOf('}');
|
|
405
|
+
const inner = head.slice(open + 1, close).trim();
|
|
406
|
+
return splice(src, hs.start - 1, 1, [head.slice(0, open).replace(/\s*$/, '') + ' {', ...(inner ? [indent + inner] : []), line, indent.slice(2) + '}']);
|
|
407
|
+
}
|
|
408
|
+
if (host.body)
|
|
409
|
+
return splice(src, hs.end - 1, 0, [line]); // before the closing brace
|
|
410
|
+
return splice(src, hs.start - 1, 1, [head.replace(/\s*$/, '') + ' {', line, indent.slice(2) + '}']);
|
|
411
|
+
}
|
|
412
|
+
/** Take a logical object off a host. A `run a, b` line loses one name; a `run a` line goes. */
|
|
413
|
+
export function deleteRun(src, hostId, ref) {
|
|
414
|
+
const { doc } = parse(src);
|
|
415
|
+
const host = findDecl(doc, hostId);
|
|
416
|
+
const r = (host?.body ?? []).find(n => n.type === 'run' && n.refs.includes(ref));
|
|
417
|
+
if (!r)
|
|
418
|
+
return src;
|
|
419
|
+
const rs = span(r.tokens);
|
|
420
|
+
if (r.refs.length > 1) {
|
|
421
|
+
const cur = src.split('\n')[rs.start - 1];
|
|
422
|
+
const next = cur.replace(new RegExp(`(run\\s+)([^{]*)`), (_m, kw, list) => {
|
|
423
|
+
const names = list.split(',').map((x) => x.trim()).filter((x) => x && x !== ref);
|
|
424
|
+
// the sizing words after the last name must survive: split names from the rest
|
|
425
|
+
return kw + list.replace(new RegExp(`\\b${ref}\\b\\s*,\\s*|,\\s*\\b${ref}\\b`), '').replace(/^\s+/, '') || names.join(', ');
|
|
426
|
+
});
|
|
427
|
+
return splice(src, rs.start - 1, 1, [next]);
|
|
428
|
+
}
|
|
429
|
+
let out = splice(src, rs.start - 1, rs.end - rs.start + 1, []);
|
|
430
|
+
out = out.replace(/^([ \t]*\w+[^\n{]*)\{\s*\n\s*\}[ \t]*$/gm, (_m, head) => head.replace(/\s+$/, ''));
|
|
431
|
+
return out;
|
|
432
|
+
}
|
|
433
|
+
/** Re-size a placement: `run checkout` → `run checkout replicas 3 cpu 2`. A shared line is split first. */
|
|
434
|
+
export function setRunSizing(src, hostId, ref, sizing) {
|
|
435
|
+
const out = deleteRun(src, hostId, ref);
|
|
436
|
+
return addRun(out === src && !hasRun(src, hostId, ref) ? src : out, hostId, ref, sizing);
|
|
437
|
+
}
|
|
438
|
+
function hasRun(src, hostId, ref) {
|
|
439
|
+
const { doc } = parse(src);
|
|
440
|
+
const host = findDecl(doc, hostId);
|
|
441
|
+
return (host?.body ?? []).some(n => n.type === 'run' && n.refs.includes(ref));
|
|
442
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export { lex } from './lexer.js';
|
|
2
|
+
export { parse, type ParseResult } from './parser.js';
|
|
3
|
+
export { serialize } from './serialize.js';
|
|
4
|
+
export { lower, type Model, type ModelObject, type ModelRelation, type Interface, type Placement } from './model.js';
|
|
5
|
+
export type { Doc, Decl, Attr, Rel, Run, Iface, Raw, Blank, Node } from './cst.js';
|
|
6
|
+
export type { Token, TokenKind } from './tokens.js';
|
|
7
|
+
export type { Diagnostic, Severity } from './diagnostics.js';
|
|
8
|
+
export { OBJECT_KINDS, VERBS, IFACE_VERBS, PLACEMENT_KINDS, RESERVED_KINDS, ATTR_KEYS } from './vocab.js';
|
|
9
|
+
export { check, LANGUAGE_VERSIONS } from './check.js';
|
|
10
|
+
export { toCompiled, toJSON, toYAML, type Compiled } from './compiled.js';
|
|
11
|
+
/** True when `parse → serialize` reproduces the input byte for byte. */
|
|
12
|
+
export declare function roundTrips(src: string): boolean;
|
|
13
|
+
export { applyLens, metaLine, footerOf, AGENT_ROW, type Lens, type RenderGraph, type RenderNode, type RenderEdge } from './lens.js';
|
|
14
|
+
export * from './layout/ortho.js';
|
|
15
|
+
export { layout, layoutLayered, type Layout, type LayoutOptions, type Positioned, type RoutedEdge } from './layout/elk.js';
|
|
16
|
+
export { layoutTwoPhase, type BalanceOptions } from './layout/balance.js';
|
|
17
|
+
export { routeSimple, routeAround, routeAll, simplify, type Pt, type Rect, type Side } from './layout/route.js';
|
|
18
|
+
export { toSvg, metaFits, THEMES } from './render/svg.js';
|
|
19
|
+
export { configureElk, elkInstance, useElkWorker } from './layout/host.js';
|
|
20
|
+
export type { ElkLike } from './layout/host.js';
|
|
21
|
+
export type { Theme, ThemeName } from './render/svg.js';
|
|
22
|
+
export { capacityTable, toGiB, toCpu, fmt, FIGURE_KEYS, effectiveSizing, hostLoad, SIZING_KEYS, type HostLoad, type CapacityTable, type CapacityEnv, type CapacityRow, type Figures } from './capacity.js';
|
|
23
|
+
export { labelAnchor, labelBox, fractionAlong, flippedSide, roundedPath } from './layout/label.js';
|
|
24
|
+
export { textWidth, nodeBox } from './layout/measure.js';
|
|
25
|
+
export { addObject, deleteObject, renameObject, setDisplayName, setAttribute, addRelation, deleteRelation, retargetRelation, setRelationVerb, setRelationAttr, moveInto, moveOut, insertStatement, addRun, deleteRun, setRunSizing, } from './edit.js';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export { lex } from './lexer.js';
|
|
2
|
+
export { parse } from './parser.js';
|
|
3
|
+
export { serialize } from './serialize.js';
|
|
4
|
+
export { lower } from './model.js';
|
|
5
|
+
export { OBJECT_KINDS, VERBS, IFACE_VERBS, PLACEMENT_KINDS, RESERVED_KINDS, ATTR_KEYS } from './vocab.js';
|
|
6
|
+
export { check, LANGUAGE_VERSIONS } from './check.js';
|
|
7
|
+
export { toCompiled, toJSON, toYAML } from './compiled.js';
|
|
8
|
+
import { parse } from './parser.js';
|
|
9
|
+
import { serialize } from './serialize.js';
|
|
10
|
+
/** True when `parse → serialize` reproduces the input byte for byte. */
|
|
11
|
+
export function roundTrips(src) {
|
|
12
|
+
return serialize(parse(src).doc) === src;
|
|
13
|
+
}
|
|
14
|
+
export { applyLens, metaLine, footerOf, AGENT_ROW } from './lens.js';
|
|
15
|
+
export * from './layout/ortho.js';
|
|
16
|
+
export { layout, layoutLayered } from './layout/elk.js';
|
|
17
|
+
export { layoutTwoPhase } from './layout/balance.js';
|
|
18
|
+
export { routeSimple, routeAround, routeAll, simplify } from './layout/route.js';
|
|
19
|
+
export { toSvg, metaFits, THEMES } from './render/svg.js';
|
|
20
|
+
export { configureElk, elkInstance, useElkWorker } from './layout/host.js';
|
|
21
|
+
export { capacityTable, toGiB, toCpu, fmt, FIGURE_KEYS, effectiveSizing, hostLoad, SIZING_KEYS } from './capacity.js';
|
|
22
|
+
export { labelAnchor, labelBox, fractionAlong, flippedSide, roundedPath } from './layout/label.js';
|
|
23
|
+
export { textWidth, nodeBox } from './layout/measure.js';
|
|
24
|
+
export { addObject, deleteObject, renameObject, setDisplayName, setAttribute, addRelation, deleteRelation, retargetRelation, setRelationVerb, setRelationAttr, moveInto, moveOut, insertStatement, addRun, deleteRun, setRunSizing, } from './edit.js';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two-phase layout — how the reference decks are actually drawn.
|
|
3
|
+
*
|
|
4
|
+
* One layered layout over the whole tree (ELK `INCLUDE_CHILDREN`) stretches
|
|
5
|
+
* every system across every layer of the picture: tall empty frames, 10–15 %
|
|
6
|
+
* ink, aspect 0.4–0.8. Measured on the six reference architectures nothing in
|
|
7
|
+
* ELK's option space moves that by more than one point. What does is doing what
|
|
8
|
+
* a person does: lay out each system on its own, then arrange the systems.
|
|
9
|
+
*
|
|
10
|
+
* 1. every top-level frame is laid out separately, top-down or left-to-right —
|
|
11
|
+
* whichever lands nearer the target aspect;
|
|
12
|
+
* 2. the top level is laid out again with frames as opaque nodes carrying
|
|
13
|
+
* fixed ports on the border facing each partner (people first, downstream
|
|
14
|
+
* third parties last);
|
|
15
|
+
* 3. the leg from a port to the card inside is routed around the frame's other
|
|
16
|
+
* members and its title text.
|
|
17
|
+
*
|
|
18
|
+
* Deterministic, ~150 ms on the references, and it scores 53 against 39 for the
|
|
19
|
+
* single pass (aspect 1.3–2.2, no edge through a card).
|
|
20
|
+
*/
|
|
21
|
+
import { type RenderGraph } from '../lens.js';
|
|
22
|
+
import { type Layout, type Positioned } from './elk.js';
|
|
23
|
+
import { type Rect } from './route.js';
|
|
24
|
+
export interface BalanceOptions {
|
|
25
|
+
/** Width ÷ height the picture should approach (the canvas, or 16:9). */
|
|
26
|
+
target?: number;
|
|
27
|
+
/** Direction inside every frame; `auto` picks per frame by the target aspect. */
|
|
28
|
+
dir?: 'auto' | 'DOWN' | 'RIGHT';
|
|
29
|
+
/** Wrap long chains inside frames (ELK multi-edge wrapping) — for very long pipelines. */
|
|
30
|
+
wrap?: boolean;
|
|
31
|
+
}
|
|
32
|
+
/** Obstacles for a leg inside a frame: every other card whole, nested frames' title strips. */
|
|
33
|
+
export declare function obstaclesFor(nodes: Positioned[], a: Positioned, b: Positioned): Rect[];
|
|
34
|
+
export declare function layoutTwoPhase(g: RenderGraph, opts?: BalanceOptions): Promise<Layout>;
|