@purview-dev/astroc4 1.0.0-prerelease.1 → 1.0.0-prerelease.2

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.
@@ -1,559 +1,559 @@
1
- // langium.mjs
2
- // path: src/grammar/langium.mjs
3
- // Reduces the LikeC4 Langium grammar to the vocabulary a TextMate grammar
4
- // needs: which words open a block, which are plain keywords, which are
5
- // property names, which belong to a closed value domain (shapes, colours,
6
- // arrow heads…) and which tokens have to be matched structurally (comments,
7
- // strings, icon ids).
8
- //
9
- // The grammar is read from likec4/likec4 at a ref the site chooses:
10
- // `packages/language-server/src/like-c4.langium`. Deriving from it is what
11
- // keeps highlighting in step with the language — `ElementShape` gained `agent`
12
- // after v1.59.4, and the TextMate grammar upstream maintains by hand still
13
- // does not know about it.
14
- //
15
- // Deliberately no Langium dependency: this is a tolerant scanner over one
16
- // file, not a parser for the grammar language, and it has to survive upstream
17
- // reformatting. Anything it cannot classify is dropped rather than guessed.
18
-
19
- /** Rules that describe a closed set of values are named after what they hold. */
20
- const NOT_VALUE_DOMAINS = /Id$/;
21
-
22
- /** Property names the grammar matches as `key='…'` assignments. */
23
- const PROPERTY_SUPPLEMENTS = ['kind', 'tag'];
24
-
25
- /**
26
- * @typedef {object} Terminal
27
- * @property {boolean} hidden
28
- * @property {'regex' | 'literal'} kind
29
- * @property {string} value Raw regex source (without slashes) or literal.
30
- */
31
-
32
- /**
33
- * @typedef {object} Vocabulary
34
- * @property {string[]} blocks Keywords that open a `{ … }` block.
35
- * @property {string[]} keywords Every remaining word-like literal.
36
- * @property {string[]} properties Property names from `key='…'` assignments.
37
- * @property {string[]} arrows Relationship connectors containing `->`.
38
- * @property {Record<string, string[]>} enums `returns string` value domains.
39
- * @property {string[]} icons Icon packs from the `LIB_ICON` terminal.
40
- * @property {string[]} booleans Literals from the `BOOLEAN` terminal.
41
- * @property {number} ruleCount Parser rules seen — used for sanity checks.
42
- */
43
-
44
- /**
45
- * @param {string} source
46
- * @returns {Vocabulary}
47
- */
48
- export function parseLangium(source) {
49
- // `grammar LikeC4` is a header, not a rule, and it has no `;`: leaving it in
50
- // would glue it to the first real rule and hide that rule from the scanner.
51
- const statements = splitStatements(
52
- stripComments(source).replace(/^[ \t]*grammar[ \t]+[A-Za-z_]\w*[ \t]*$/gm, ''),
53
- );
54
- const rules = statements.map(readStatement).filter((rule) => rule !== undefined);
55
-
56
- const parserRules = rules.filter((rule) => rule.kind === 'rule' || rule.kind === 'fragment');
57
-
58
- if (parserRules.length === 0) {
59
- throw new Error(
60
- 'the file contains no Langium parser rules — is this still `packages/language-server/src/like-c4.langium`?',
61
- );
62
- }
63
-
64
- const enums = resolveEnums(parserRules);
65
- const blocks = findBlocks(parserRules);
66
- const properties = unique([...findProperties(parserRules), ...PROPERTY_SUPPLEMENTS]);
67
- const arrows = findArrows(parserRules);
68
- const literals = unique(
69
- parserRules.flatMap((rule) => rule.tokens.filter((t) => t.type === 'literal').map((t) => t.value)),
70
- );
71
-
72
- const classified = new Set([...blocks, ...properties, ...Object.values(enums).flat()]);
73
- const keywords = literals.filter(
74
- (value) => /^[A-Za-z_]\w*$/.test(value) && !classified.has(value),
75
- );
76
-
77
- const terminals = readTerminals(rules);
78
-
79
- // A grammar with parser rules but no structure at all is not a LikeC4
80
- // grammar — most likely the path now holds something else. A small grammar
81
- // can legitimately have no "leftover" keywords, so blocks and value domains
82
- // count too.
83
- if (keywords.length === 0 && blocks.length === 0 && Object.keys(enums).length === 0) {
84
- throw new Error('the file contains no LikeC4 keywords — the Langium grammar could not be read');
85
- }
86
-
87
- return {
88
- blocks,
89
- keywords,
90
- properties,
91
- arrows,
92
- enums,
93
- icons: terminalAlternatives(terminals.LIB_ICON),
94
- booleans: terminalAlternatives(terminals.BOOLEAN),
95
- ruleCount: parserRules.length,
96
- };
97
- }
98
-
99
- // region scanning
100
-
101
- /**
102
- * Blank out `//` and block comments. String and regex literals are copied
103
- * verbatim, because a `//` inside `/https?:\/\//` is not a comment.
104
- * @param {string} source
105
- */
106
- function stripComments(source) {
107
- let out = '';
108
- let i = 0;
109
-
110
- while (i < source.length) {
111
- const char = source[i];
112
-
113
- if (char === '/' && source[i + 1] === '/') {
114
- while (i < source.length && source[i] !== '\n') i++;
115
- out += ' ';
116
- continue;
117
- }
118
-
119
- if (char === '/' && source[i + 1] === '*') {
120
- i += 2;
121
- while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) i++;
122
- i += 2;
123
- out += ' ';
124
- continue;
125
- }
126
-
127
- if (char === "'" || char === '"') {
128
- const end = endOfString(source, i);
129
- out += source.slice(i, end);
130
- i = end;
131
- continue;
132
- }
133
-
134
- if (char === '/') {
135
- const end = endOfRegex(source, i);
136
- out += source.slice(i, end);
137
- i = end;
138
- continue;
139
- }
140
-
141
- out += char;
142
- i++;
143
- }
144
-
145
- return out;
146
- }
147
-
148
- /** Index just past the quoted literal that opens at `start`. */
149
- function endOfString(source, start) {
150
- const quote = source[start];
151
- let i = start + 1;
152
-
153
- while (i < source.length) {
154
- if (source[i] === '\\') {
155
- i += 2;
156
- continue;
157
- }
158
- if (source[i] === quote) return i + 1;
159
- i++;
160
- }
161
-
162
- return source.length;
163
- }
164
-
165
- /** Index just past the regex literal that opens at `start`. */
166
- function endOfRegex(source, start) {
167
- let i = start + 1;
168
- let inClass = false;
169
-
170
- while (i < source.length) {
171
- const char = source[i];
172
-
173
- if (char === '\\') {
174
- i += 2;
175
- continue;
176
- }
177
- if (char === '[') inClass = true;
178
- else if (char === ']') inClass = false;
179
- else if (char === '/' && !inClass) return i + 1;
180
-
181
- i++;
182
- }
183
-
184
- return source.length;
185
- }
186
-
187
- /**
188
- * Split on the `;` that ends each rule, ignoring the ones inside strings,
189
- * regexes and `{ … }` — interfaces have fields, and rules have `{infer X}`
190
- * actions, both of which can contain semicolons.
191
- * @param {string} source
192
- */
193
- function splitStatements(source) {
194
- const statements = [];
195
- let depth = 0;
196
- let start = 0;
197
- let i = 0;
198
-
199
- while (i < source.length) {
200
- const char = source[i];
201
-
202
- if (char === "'" || char === '"') {
203
- i = endOfString(source, i);
204
- continue;
205
- }
206
-
207
- if (char === '/') {
208
- i = endOfRegex(source, i);
209
- continue;
210
- }
211
-
212
- if (char === '{') depth++;
213
- else if (char === '}') depth = Math.max(0, depth - 1);
214
- else if (char === ';' && depth === 0) {
215
- statements.push(source.slice(start, i));
216
- start = i + 1;
217
- }
218
-
219
- i++;
220
- }
221
-
222
- const tail = source.slice(start).trim();
223
- if (tail.length > 0) statements.push(tail);
224
-
225
- return statements.filter((statement) => statement.trim().length > 0);
226
- }
227
-
228
- const HEADERS = [
229
- // `hidden terminal WS: /[ \t]+/`
230
- { kind: 'terminal', hidden: true, pattern: /^\s*hidden\s+terminal\s+([A-Za-z_]\w*)\s*(?:<[^>]*>)?\s*:/ },
231
- // `terminal LIB_ICON: /…/`, `terminal fragment HASH: '#'`
232
- {
233
- kind: 'terminal',
234
- hidden: false,
235
- pattern:
236
- /^\s*terminal\s+(?:fragment\s+)?([A-Za-z_]\w*)\s*(?:<[^>]*>)?\s*(?:returns\s+[A-Za-z_]\w*\s*)?:/,
237
- },
238
- // `fragment RelationConnector:`
239
- { kind: 'fragment', hidden: false, pattern: /^\s*fragment\s+([A-Za-z_]\w*)\s*:/ },
240
- // `Model:`, `ModelViews:`, `entry LikeC4Grammar:`, `Relation<isExplicit>:`
241
- {
242
- kind: 'rule',
243
- hidden: false,
244
- pattern: /^\s*(?:entry\s+)?([A-Za-z_]\w*)\s*(?:<[^>]*>)?\s*(?:returns\s+([A-Za-z_]\w*)\s*)?:/,
245
- },
246
- ];
247
-
248
- /**
249
- * @param {string} statement
250
- * @returns {{ name: string, kind: 'rule' | 'fragment' | 'terminal', hidden: boolean, returns?: string, tokens: Token[] } | undefined}
251
- */
252
- function readStatement(statement) {
253
- for (const header of HEADERS) {
254
- const match = header.pattern.exec(statement);
255
- if (!match) continue;
256
-
257
- return {
258
- name: match[1],
259
- kind: header.kind,
260
- hidden: header.hidden,
261
- returns: header.kind === 'rule' ? match[2] : undefined,
262
- tokens: tokenize(statement.slice(match[0].length)),
263
- };
264
- }
265
-
266
- return undefined;
267
- }
268
-
269
- /**
270
- * @typedef {'literal' | 'regex' | 'ident' | 'punct'} TokenType
271
- * @typedef {{ type: TokenType, value: string }} Token
272
- */
273
-
274
- /**
275
- * @param {string} body
276
- * @returns {Token[]}
277
- */
278
- function tokenize(body) {
279
- /** @type {Token[]} */
280
- const tokens = [];
281
- let i = 0;
282
-
283
- while (i < body.length) {
284
- const char = body[i];
285
-
286
- if (/\s/.test(char)) {
287
- i++;
288
- continue;
289
- }
290
-
291
- if (char === "'" || char === '"') {
292
- const end = endOfString(body, i);
293
- tokens.push({ type: 'literal', value: unquote(body.slice(i, end)) });
294
- i = end;
295
- continue;
296
- }
297
-
298
- if (char === '/') {
299
- const end = endOfRegex(body, i);
300
- tokens.push({ type: 'regex', value: body.slice(i + 1, end - 1) });
301
- i = end;
302
- continue;
303
- }
304
-
305
- const identifier = /^[A-Za-z_]\w*/.exec(body.slice(i));
306
- if (identifier) {
307
- tokens.push({ type: 'ident', value: identifier[0] });
308
- i += identifier[0].length;
309
- continue;
310
- }
311
-
312
- // Multi-character punctuation (`+=`, `?=`) is irrelevant: only `=`, `|`,
313
- // `(` and `)` are inspected, and per-character tokens give all four.
314
- tokens.push({ type: 'punct', value: char });
315
- i++;
316
- }
317
-
318
- return tokens;
319
- }
320
-
321
- /** Strip the quotes and resolve the escapes Langium allows in literals. */
322
- function unquote(quoted) {
323
- const inner = quoted.slice(1, -1);
324
- let out = '';
325
-
326
- for (let i = 0; i < inner.length; i++) {
327
- if (inner[i] !== '\\') {
328
- out += inner[i];
329
- continue;
330
- }
331
-
332
- const escaped = inner[++i];
333
- if (escaped === 'n') out += '\n';
334
- else if (escaped === 't') out += '\t';
335
- else if (escaped === 'r') out += '\r';
336
- else out += escaped ?? '';
337
- }
338
-
339
- return out;
340
- }
341
-
342
- // endregion
343
-
344
- // region vocabulary
345
-
346
- /**
347
- * Rules of the shape `'specification' '{' …` or `name='model' '{' …` open a
348
- * block. The first literal of the rule is the keyword, and it only counts when
349
- * the rule reaches a `{` without branching first: `'element' kind=ElementKind
350
- * ('{' …)` has an optional body, so `element` stays a keyword.
351
- * @param {{ tokens: Token[] }[]} rules
352
- */
353
- function findBlocks(rules) {
354
- return unique(
355
- rules.flatMap((rule) => {
356
- const tokens = rule.tokens;
357
- const first = tokens.findIndex((token) => token.type === 'literal');
358
- const keyword = first === -1 ? undefined : tokens[first].value;
359
-
360
- if (!keyword || keyword === '{' || !/^[A-Za-z_]\w*$/.test(keyword)) return [];
361
-
362
- for (let i = first + 1; i < tokens.length; i++) {
363
- if (tokens[i].value === '(' || tokens[i].value === '|') return [];
364
- if (tokens[i].type === 'literal' && tokens[i].value === '{') return [keyword];
365
- }
366
-
367
- return [];
368
- }),
369
- );
370
- }
371
-
372
- /**
373
- * `key='title'` and `key=('head' | 'tail')` name a property. Positional
374
- * arguments (`props+=String`) and `name=` (block headers) are ignored.
375
- * @param {{ tokens: Token[] }[]} rules
376
- */
377
- function findProperties(rules) {
378
- /** @type {string[]} */
379
- const properties = [];
380
-
381
- for (const rule of rules) {
382
- const tokens = rule.tokens;
383
-
384
- for (let i = 0; i < tokens.length; i++) {
385
- if (tokens[i].type !== 'ident' || tokens[i].value !== 'key') continue;
386
-
387
- const equals = equalsIndex(tokens, i);
388
- if (equals === -1) continue;
389
-
390
- let j = equals + 1;
391
-
392
- if (tokens[j]?.value === '(') {
393
- for (j++; j < tokens.length && tokens[j].value !== ')'; j++) {
394
- if (tokens[j].type === 'literal') properties.push(tokens[j].value);
395
- }
396
- continue;
397
- }
398
-
399
- if (tokens[j]?.type === 'literal') properties.push(tokens[j].value);
400
- }
401
- }
402
-
403
- return properties.filter((value) => /^[A-Za-z_]\w*$/.test(value));
404
- }
405
-
406
- /**
407
- * Index of the `=` that assigns `tokens[i]` (`key=`, `key?=`, `key+=`), or `-1`
408
- * when the identifier is not being assigned.
409
- * @param {Token[]} tokens
410
- * @param {number} i
411
- */
412
- function equalsIndex(tokens, i) {
413
- if (tokens[i]?.type !== 'ident') return -1;
414
-
415
- const next = tokens[i + 1]?.value;
416
- if (next === '=') return i + 1;
417
- if ((next === '?' || next === '+') && tokens[i + 2]?.value === '=') return i + 2;
418
-
419
- return -1;
420
- }
421
-
422
- /**
423
- * The connectors live in one fragment:
424
- * dotKind=RelationKindDotRef | '-[' kind=[RelationshipKind] (… '->') | … | '->'
425
- * @param {{ name: string, tokens: Token[] }[]} rules
426
- */
427
- function findArrows(rules) {
428
- const connector = rules.find((rule) => rule.name === 'RelationConnector');
429
- const fromConnector = (connector?.tokens ?? [])
430
- .filter((token) => token.type === 'literal' && token.value.includes('->'))
431
- .map((token) => token.value);
432
-
433
- // Longest first: TextMate alternation is first-match, so `]->` has to be
434
- // tried before `->` at the same position.
435
- return unique([...fromConnector, '->', '<->']).sort((a, b) => b.length - a.length || a.localeCompare(b));
436
- }
437
-
438
- /**
439
- * A `returns string` rule whose body is only literals, `|` and references to
440
- * other such rules is a closed value domain — `BorderStyleValue: LineOptions |
441
- * 'none'` inherits from `LineOptions` that way. Anything referencing a rule
442
- * that is not itself a value domain (e.g. `Id returns string`) resolves to
443
- * nothing and is dropped.
444
- * @param {{ name: string, returns?: string, tokens: Token[] }[]} rules
445
- */
446
- function resolveEnums(rules) {
447
- /** @type {Map<string, string[]>} */
448
- const candidates = new Map();
449
-
450
- for (const rule of rules) {
451
- if (rule.returns !== 'string' || NOT_VALUE_DOMAINS.test(rule.name)) continue;
452
-
453
- const onlyValuesAndChoices = rule.tokens.every(
454
- (token) =>
455
- token.type === 'literal' ||
456
- token.type === 'ident' ||
457
- (token.type === 'punct' && (token.value === '|' || token.value === ';')),
458
- );
459
-
460
- if (onlyValuesAndChoices) candidates.set(rule.name, []);
461
- }
462
-
463
- // Fixpoint, because `BorderStyleValue` can only resolve once `LineOptions`
464
- // has. One pass per rule is enough for any acyclic grammar.
465
- for (let pass = 0; pass < rules.length; pass++) {
466
- let changed = false;
467
-
468
- for (const rule of rules) {
469
- if (!candidates.has(rule.name)) continue;
470
-
471
- /** @type {string[]} */
472
- const resolved = [];
473
- let complete = true;
474
-
475
- for (const token of rule.tokens) {
476
- if (token.type === 'literal') {
477
- resolved.push(token.value);
478
- continue;
479
- }
480
-
481
- if (token.type !== 'ident') continue;
482
-
483
- const inherited = candidates.get(token.value);
484
- if (inherited === undefined) {
485
- complete = false;
486
- break;
487
- }
488
- resolved.push(...inherited);
489
- }
490
-
491
- const next = unique(resolved);
492
- if (!complete || next.length === 0) continue;
493
-
494
- if ((candidates.get(rule.name) ?? []).length !== next.length) {
495
- candidates.set(rule.name, next);
496
- changed = true;
497
- }
498
- }
499
-
500
- if (!changed) break;
501
- }
502
-
503
- /** @type {Record<string, string[]>} */
504
- const enums = {};
505
- for (const [name, values] of [...candidates].sort(([a], [b]) => a.localeCompare(b))) {
506
- if (values.length > 0) enums[name] = values;
507
- }
508
-
509
- return enums;
510
- }
511
-
512
- /**
513
- * @param {{ name: string, kind: string, hidden: boolean, tokens: Token[] }[]} rules
514
- * @returns {Record<string, Terminal>}
515
- */
516
- function readTerminals(rules) {
517
- /** @type {Record<string, Terminal>} */
518
- const terminals = {};
519
-
520
- for (const rule of rules) {
521
- if (rule.kind !== 'terminal') continue;
522
-
523
- const token = rule.tokens.find((candidate) => candidate.type === 'regex' || candidate.type === 'literal');
524
- if (!token) continue;
525
-
526
- terminals[rule.name] = {
527
- hidden: rule.hidden,
528
- kind: token.type === 'regex' ? 'regex' : 'literal',
529
- value: token.value,
530
- };
531
- }
532
-
533
- return terminals;
534
- }
535
-
536
- /**
537
- * The alternatives of the first group in a terminal's regex:
538
- * `(aws|azure|bootstrap|gcp|md|tech):[-\w]+` -> the icon packs.
539
- * @param {Terminal | undefined} terminal
540
- */
541
- function terminalAlternatives(terminal) {
542
- if (!terminal || terminal.kind !== 'regex') return [];
543
-
544
- const group = /\(([^()]*)\)/.exec(terminal.value);
545
- if (!group) return [];
546
-
547
- return unique(group[1].split('|').map((value) => value.trim())).filter((value) =>
548
- /^[A-Za-z][\w-]*$/.test(value),
549
- );
550
- }
551
-
552
- function unique(values) {
553
- return [...new Set(values)].sort((a, b) => a.localeCompare(b));
554
- }
555
-
556
- // endregion
557
-
558
- export default parseLangium;
559
-
1
+ // langium.mjs
2
+ // path: src/grammar/langium.mjs
3
+ // Reduces the LikeC4 Langium grammar to the vocabulary a TextMate grammar
4
+ // needs: which words open a block, which are plain keywords, which are
5
+ // property names, which belong to a closed value domain (shapes, colours,
6
+ // arrow heads…) and which tokens have to be matched structurally (comments,
7
+ // strings, icon ids).
8
+ //
9
+ // The grammar is read from likec4/likec4 at a ref the site chooses:
10
+ // `packages/language-server/src/like-c4.langium`. Deriving from it is what
11
+ // keeps highlighting in step with the language — `ElementShape` gained `agent`
12
+ // after v1.59.4, and the TextMate grammar upstream maintains by hand still
13
+ // does not know about it.
14
+ //
15
+ // Deliberately no Langium dependency: this is a tolerant scanner over one
16
+ // file, not a parser for the grammar language, and it has to survive upstream
17
+ // reformatting. Anything it cannot classify is dropped rather than guessed.
18
+
19
+ /** Rules that describe a closed set of values are named after what they hold. */
20
+ const NOT_VALUE_DOMAINS = /Id$/;
21
+
22
+ /** Property names the grammar matches as `key='…'` assignments. */
23
+ const PROPERTY_SUPPLEMENTS = ['kind', 'tag'];
24
+
25
+ /**
26
+ * @typedef {object} Terminal
27
+ * @property {boolean} hidden
28
+ * @property {'regex' | 'literal'} kind
29
+ * @property {string} value Raw regex source (without slashes) or literal.
30
+ */
31
+
32
+ /**
33
+ * @typedef {object} Vocabulary
34
+ * @property {string[]} blocks Keywords that open a `{ … }` block.
35
+ * @property {string[]} keywords Every remaining word-like literal.
36
+ * @property {string[]} properties Property names from `key='…'` assignments.
37
+ * @property {string[]} arrows Relationship connectors containing `->`.
38
+ * @property {Record<string, string[]>} enums `returns string` value domains.
39
+ * @property {string[]} icons Icon packs from the `LIB_ICON` terminal.
40
+ * @property {string[]} booleans Literals from the `BOOLEAN` terminal.
41
+ * @property {number} ruleCount Parser rules seen — used for sanity checks.
42
+ */
43
+
44
+ /**
45
+ * @param {string} source
46
+ * @returns {Vocabulary}
47
+ */
48
+ export function parseLangium(source) {
49
+ // `grammar LikeC4` is a header, not a rule, and it has no `;`: leaving it in
50
+ // would glue it to the first real rule and hide that rule from the scanner.
51
+ const statements = splitStatements(
52
+ stripComments(source).replace(/^[ \t]*grammar[ \t]+[A-Za-z_]\w*[ \t]*$/gm, ''),
53
+ );
54
+ const rules = statements.map(readStatement).filter((rule) => rule !== undefined);
55
+
56
+ const parserRules = rules.filter((rule) => rule.kind === 'rule' || rule.kind === 'fragment');
57
+
58
+ if (parserRules.length === 0) {
59
+ throw new Error(
60
+ 'the file contains no Langium parser rules — is this still `packages/language-server/src/like-c4.langium`?',
61
+ );
62
+ }
63
+
64
+ const enums = resolveEnums(parserRules);
65
+ const blocks = findBlocks(parserRules);
66
+ const properties = unique([...findProperties(parserRules), ...PROPERTY_SUPPLEMENTS]);
67
+ const arrows = findArrows(parserRules);
68
+ const literals = unique(
69
+ parserRules.flatMap((rule) => rule.tokens.filter((t) => t.type === 'literal').map((t) => t.value)),
70
+ );
71
+
72
+ const classified = new Set([...blocks, ...properties, ...Object.values(enums).flat()]);
73
+ const keywords = literals.filter(
74
+ (value) => /^[A-Za-z_]\w*$/.test(value) && !classified.has(value),
75
+ );
76
+
77
+ const terminals = readTerminals(rules);
78
+
79
+ // A grammar with parser rules but no structure at all is not a LikeC4
80
+ // grammar — most likely the path now holds something else. A small grammar
81
+ // can legitimately have no "leftover" keywords, so blocks and value domains
82
+ // count too.
83
+ if (keywords.length === 0 && blocks.length === 0 && Object.keys(enums).length === 0) {
84
+ throw new Error('the file contains no LikeC4 keywords — the Langium grammar could not be read');
85
+ }
86
+
87
+ return {
88
+ blocks,
89
+ keywords,
90
+ properties,
91
+ arrows,
92
+ enums,
93
+ icons: terminalAlternatives(terminals.LIB_ICON),
94
+ booleans: terminalAlternatives(terminals.BOOLEAN),
95
+ ruleCount: parserRules.length,
96
+ };
97
+ }
98
+
99
+ // region scanning
100
+
101
+ /**
102
+ * Blank out `//` and block comments. String and regex literals are copied
103
+ * verbatim, because a `//` inside `/https?:\/\//` is not a comment.
104
+ * @param {string} source
105
+ */
106
+ function stripComments(source) {
107
+ let out = '';
108
+ let i = 0;
109
+
110
+ while (i < source.length) {
111
+ const char = source[i];
112
+
113
+ if (char === '/' && source[i + 1] === '/') {
114
+ while (i < source.length && source[i] !== '\n') i++;
115
+ out += ' ';
116
+ continue;
117
+ }
118
+
119
+ if (char === '/' && source[i + 1] === '*') {
120
+ i += 2;
121
+ while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) i++;
122
+ i += 2;
123
+ out += ' ';
124
+ continue;
125
+ }
126
+
127
+ if (char === "'" || char === '"') {
128
+ const end = endOfString(source, i);
129
+ out += source.slice(i, end);
130
+ i = end;
131
+ continue;
132
+ }
133
+
134
+ if (char === '/') {
135
+ const end = endOfRegex(source, i);
136
+ out += source.slice(i, end);
137
+ i = end;
138
+ continue;
139
+ }
140
+
141
+ out += char;
142
+ i++;
143
+ }
144
+
145
+ return out;
146
+ }
147
+
148
+ /** Index just past the quoted literal that opens at `start`. */
149
+ function endOfString(source, start) {
150
+ const quote = source[start];
151
+ let i = start + 1;
152
+
153
+ while (i < source.length) {
154
+ if (source[i] === '\\') {
155
+ i += 2;
156
+ continue;
157
+ }
158
+ if (source[i] === quote) return i + 1;
159
+ i++;
160
+ }
161
+
162
+ return source.length;
163
+ }
164
+
165
+ /** Index just past the regex literal that opens at `start`. */
166
+ function endOfRegex(source, start) {
167
+ let i = start + 1;
168
+ let inClass = false;
169
+
170
+ while (i < source.length) {
171
+ const char = source[i];
172
+
173
+ if (char === '\\') {
174
+ i += 2;
175
+ continue;
176
+ }
177
+ if (char === '[') inClass = true;
178
+ else if (char === ']') inClass = false;
179
+ else if (char === '/' && !inClass) return i + 1;
180
+
181
+ i++;
182
+ }
183
+
184
+ return source.length;
185
+ }
186
+
187
+ /**
188
+ * Split on the `;` that ends each rule, ignoring the ones inside strings,
189
+ * regexes and `{ … }` — interfaces have fields, and rules have `{infer X}`
190
+ * actions, both of which can contain semicolons.
191
+ * @param {string} source
192
+ */
193
+ function splitStatements(source) {
194
+ const statements = [];
195
+ let depth = 0;
196
+ let start = 0;
197
+ let i = 0;
198
+
199
+ while (i < source.length) {
200
+ const char = source[i];
201
+
202
+ if (char === "'" || char === '"') {
203
+ i = endOfString(source, i);
204
+ continue;
205
+ }
206
+
207
+ if (char === '/') {
208
+ i = endOfRegex(source, i);
209
+ continue;
210
+ }
211
+
212
+ if (char === '{') depth++;
213
+ else if (char === '}') depth = Math.max(0, depth - 1);
214
+ else if (char === ';' && depth === 0) {
215
+ statements.push(source.slice(start, i));
216
+ start = i + 1;
217
+ }
218
+
219
+ i++;
220
+ }
221
+
222
+ const tail = source.slice(start).trim();
223
+ if (tail.length > 0) statements.push(tail);
224
+
225
+ return statements.filter((statement) => statement.trim().length > 0);
226
+ }
227
+
228
+ const HEADERS = [
229
+ // `hidden terminal WS: /[ \t]+/`
230
+ { kind: 'terminal', hidden: true, pattern: /^\s*hidden\s+terminal\s+([A-Za-z_]\w*)\s*(?:<[^>]*>)?\s*:/ },
231
+ // `terminal LIB_ICON: /…/`, `terminal fragment HASH: '#'`
232
+ {
233
+ kind: 'terminal',
234
+ hidden: false,
235
+ pattern:
236
+ /^\s*terminal\s+(?:fragment\s+)?([A-Za-z_]\w*)\s*(?:<[^>]*>)?\s*(?:returns\s+[A-Za-z_]\w*\s*)?:/,
237
+ },
238
+ // `fragment RelationConnector:`
239
+ { kind: 'fragment', hidden: false, pattern: /^\s*fragment\s+([A-Za-z_]\w*)\s*:/ },
240
+ // `Model:`, `ModelViews:`, `entry LikeC4Grammar:`, `Relation<isExplicit>:`
241
+ {
242
+ kind: 'rule',
243
+ hidden: false,
244
+ pattern: /^\s*(?:entry\s+)?([A-Za-z_]\w*)\s*(?:<[^>]*>)?\s*(?:returns\s+([A-Za-z_]\w*)\s*)?:/,
245
+ },
246
+ ];
247
+
248
+ /**
249
+ * @param {string} statement
250
+ * @returns {{ name: string, kind: 'rule' | 'fragment' | 'terminal', hidden: boolean, returns?: string, tokens: Token[] } | undefined}
251
+ */
252
+ function readStatement(statement) {
253
+ for (const header of HEADERS) {
254
+ const match = header.pattern.exec(statement);
255
+ if (!match) continue;
256
+
257
+ return {
258
+ name: match[1],
259
+ kind: header.kind,
260
+ hidden: header.hidden,
261
+ returns: header.kind === 'rule' ? match[2] : undefined,
262
+ tokens: tokenize(statement.slice(match[0].length)),
263
+ };
264
+ }
265
+
266
+ return undefined;
267
+ }
268
+
269
+ /**
270
+ * @typedef {'literal' | 'regex' | 'ident' | 'punct'} TokenType
271
+ * @typedef {{ type: TokenType, value: string }} Token
272
+ */
273
+
274
+ /**
275
+ * @param {string} body
276
+ * @returns {Token[]}
277
+ */
278
+ function tokenize(body) {
279
+ /** @type {Token[]} */
280
+ const tokens = [];
281
+ let i = 0;
282
+
283
+ while (i < body.length) {
284
+ const char = body[i];
285
+
286
+ if (/\s/.test(char)) {
287
+ i++;
288
+ continue;
289
+ }
290
+
291
+ if (char === "'" || char === '"') {
292
+ const end = endOfString(body, i);
293
+ tokens.push({ type: 'literal', value: unquote(body.slice(i, end)) });
294
+ i = end;
295
+ continue;
296
+ }
297
+
298
+ if (char === '/') {
299
+ const end = endOfRegex(body, i);
300
+ tokens.push({ type: 'regex', value: body.slice(i + 1, end - 1) });
301
+ i = end;
302
+ continue;
303
+ }
304
+
305
+ const identifier = /^[A-Za-z_]\w*/.exec(body.slice(i));
306
+ if (identifier) {
307
+ tokens.push({ type: 'ident', value: identifier[0] });
308
+ i += identifier[0].length;
309
+ continue;
310
+ }
311
+
312
+ // Multi-character punctuation (`+=`, `?=`) is irrelevant: only `=`, `|`,
313
+ // `(` and `)` are inspected, and per-character tokens give all four.
314
+ tokens.push({ type: 'punct', value: char });
315
+ i++;
316
+ }
317
+
318
+ return tokens;
319
+ }
320
+
321
+ /** Strip the quotes and resolve the escapes Langium allows in literals. */
322
+ function unquote(quoted) {
323
+ const inner = quoted.slice(1, -1);
324
+ let out = '';
325
+
326
+ for (let i = 0; i < inner.length; i++) {
327
+ if (inner[i] !== '\\') {
328
+ out += inner[i];
329
+ continue;
330
+ }
331
+
332
+ const escaped = inner[++i];
333
+ if (escaped === 'n') out += '\n';
334
+ else if (escaped === 't') out += '\t';
335
+ else if (escaped === 'r') out += '\r';
336
+ else out += escaped ?? '';
337
+ }
338
+
339
+ return out;
340
+ }
341
+
342
+ // endregion
343
+
344
+ // region vocabulary
345
+
346
+ /**
347
+ * Rules of the shape `'specification' '{' …` or `name='model' '{' …` open a
348
+ * block. The first literal of the rule is the keyword, and it only counts when
349
+ * the rule reaches a `{` without branching first: `'element' kind=ElementKind
350
+ * ('{' …)` has an optional body, so `element` stays a keyword.
351
+ * @param {{ tokens: Token[] }[]} rules
352
+ */
353
+ function findBlocks(rules) {
354
+ return unique(
355
+ rules.flatMap((rule) => {
356
+ const tokens = rule.tokens;
357
+ const first = tokens.findIndex((token) => token.type === 'literal');
358
+ const keyword = first === -1 ? undefined : tokens[first].value;
359
+
360
+ if (!keyword || keyword === '{' || !/^[A-Za-z_]\w*$/.test(keyword)) return [];
361
+
362
+ for (let i = first + 1; i < tokens.length; i++) {
363
+ if (tokens[i].value === '(' || tokens[i].value === '|') return [];
364
+ if (tokens[i].type === 'literal' && tokens[i].value === '{') return [keyword];
365
+ }
366
+
367
+ return [];
368
+ }),
369
+ );
370
+ }
371
+
372
+ /**
373
+ * `key='title'` and `key=('head' | 'tail')` name a property. Positional
374
+ * arguments (`props+=String`) and `name=` (block headers) are ignored.
375
+ * @param {{ tokens: Token[] }[]} rules
376
+ */
377
+ function findProperties(rules) {
378
+ /** @type {string[]} */
379
+ const properties = [];
380
+
381
+ for (const rule of rules) {
382
+ const tokens = rule.tokens;
383
+
384
+ for (let i = 0; i < tokens.length; i++) {
385
+ if (tokens[i].type !== 'ident' || tokens[i].value !== 'key') continue;
386
+
387
+ const equals = equalsIndex(tokens, i);
388
+ if (equals === -1) continue;
389
+
390
+ let j = equals + 1;
391
+
392
+ if (tokens[j]?.value === '(') {
393
+ for (j++; j < tokens.length && tokens[j].value !== ')'; j++) {
394
+ if (tokens[j].type === 'literal') properties.push(tokens[j].value);
395
+ }
396
+ continue;
397
+ }
398
+
399
+ if (tokens[j]?.type === 'literal') properties.push(tokens[j].value);
400
+ }
401
+ }
402
+
403
+ return properties.filter((value) => /^[A-Za-z_]\w*$/.test(value));
404
+ }
405
+
406
+ /**
407
+ * Index of the `=` that assigns `tokens[i]` (`key=`, `key?=`, `key+=`), or `-1`
408
+ * when the identifier is not being assigned.
409
+ * @param {Token[]} tokens
410
+ * @param {number} i
411
+ */
412
+ function equalsIndex(tokens, i) {
413
+ if (tokens[i]?.type !== 'ident') return -1;
414
+
415
+ const next = tokens[i + 1]?.value;
416
+ if (next === '=') return i + 1;
417
+ if ((next === '?' || next === '+') && tokens[i + 2]?.value === '=') return i + 2;
418
+
419
+ return -1;
420
+ }
421
+
422
+ /**
423
+ * The connectors live in one fragment:
424
+ * dotKind=RelationKindDotRef | '-[' kind=[RelationshipKind] (… '->') | … | '->'
425
+ * @param {{ name: string, tokens: Token[] }[]} rules
426
+ */
427
+ function findArrows(rules) {
428
+ const connector = rules.find((rule) => rule.name === 'RelationConnector');
429
+ const fromConnector = (connector?.tokens ?? [])
430
+ .filter((token) => token.type === 'literal' && token.value.includes('->'))
431
+ .map((token) => token.value);
432
+
433
+ // Longest first: TextMate alternation is first-match, so `]->` has to be
434
+ // tried before `->` at the same position.
435
+ return unique([...fromConnector, '->', '<->']).sort((a, b) => b.length - a.length || a.localeCompare(b));
436
+ }
437
+
438
+ /**
439
+ * A `returns string` rule whose body is only literals, `|` and references to
440
+ * other such rules is a closed value domain — `BorderStyleValue: LineOptions |
441
+ * 'none'` inherits from `LineOptions` that way. Anything referencing a rule
442
+ * that is not itself a value domain (e.g. `Id returns string`) resolves to
443
+ * nothing and is dropped.
444
+ * @param {{ name: string, returns?: string, tokens: Token[] }[]} rules
445
+ */
446
+ function resolveEnums(rules) {
447
+ /** @type {Map<string, string[]>} */
448
+ const candidates = new Map();
449
+
450
+ for (const rule of rules) {
451
+ if (rule.returns !== 'string' || NOT_VALUE_DOMAINS.test(rule.name)) continue;
452
+
453
+ const onlyValuesAndChoices = rule.tokens.every(
454
+ (token) =>
455
+ token.type === 'literal' ||
456
+ token.type === 'ident' ||
457
+ (token.type === 'punct' && (token.value === '|' || token.value === ';')),
458
+ );
459
+
460
+ if (onlyValuesAndChoices) candidates.set(rule.name, []);
461
+ }
462
+
463
+ // Fixpoint, because `BorderStyleValue` can only resolve once `LineOptions`
464
+ // has. One pass per rule is enough for any acyclic grammar.
465
+ for (let pass = 0; pass < rules.length; pass++) {
466
+ let changed = false;
467
+
468
+ for (const rule of rules) {
469
+ if (!candidates.has(rule.name)) continue;
470
+
471
+ /** @type {string[]} */
472
+ const resolved = [];
473
+ let complete = true;
474
+
475
+ for (const token of rule.tokens) {
476
+ if (token.type === 'literal') {
477
+ resolved.push(token.value);
478
+ continue;
479
+ }
480
+
481
+ if (token.type !== 'ident') continue;
482
+
483
+ const inherited = candidates.get(token.value);
484
+ if (inherited === undefined) {
485
+ complete = false;
486
+ break;
487
+ }
488
+ resolved.push(...inherited);
489
+ }
490
+
491
+ const next = unique(resolved);
492
+ if (!complete || next.length === 0) continue;
493
+
494
+ if ((candidates.get(rule.name) ?? []).length !== next.length) {
495
+ candidates.set(rule.name, next);
496
+ changed = true;
497
+ }
498
+ }
499
+
500
+ if (!changed) break;
501
+ }
502
+
503
+ /** @type {Record<string, string[]>} */
504
+ const enums = {};
505
+ for (const [name, values] of [...candidates].sort(([a], [b]) => a.localeCompare(b))) {
506
+ if (values.length > 0) enums[name] = values;
507
+ }
508
+
509
+ return enums;
510
+ }
511
+
512
+ /**
513
+ * @param {{ name: string, kind: string, hidden: boolean, tokens: Token[] }[]} rules
514
+ * @returns {Record<string, Terminal>}
515
+ */
516
+ function readTerminals(rules) {
517
+ /** @type {Record<string, Terminal>} */
518
+ const terminals = {};
519
+
520
+ for (const rule of rules) {
521
+ if (rule.kind !== 'terminal') continue;
522
+
523
+ const token = rule.tokens.find((candidate) => candidate.type === 'regex' || candidate.type === 'literal');
524
+ if (!token) continue;
525
+
526
+ terminals[rule.name] = {
527
+ hidden: rule.hidden,
528
+ kind: token.type === 'regex' ? 'regex' : 'literal',
529
+ value: token.value,
530
+ };
531
+ }
532
+
533
+ return terminals;
534
+ }
535
+
536
+ /**
537
+ * The alternatives of the first group in a terminal's regex:
538
+ * `(aws|azure|bootstrap|gcp|md|tech):[-\w]+` -> the icon packs.
539
+ * @param {Terminal | undefined} terminal
540
+ */
541
+ function terminalAlternatives(terminal) {
542
+ if (!terminal || terminal.kind !== 'regex') return [];
543
+
544
+ const group = /\(([^()]*)\)/.exec(terminal.value);
545
+ if (!group) return [];
546
+
547
+ return unique(group[1].split('|').map((value) => value.trim())).filter((value) =>
548
+ /^[A-Za-z][\w-]*$/.test(value),
549
+ );
550
+ }
551
+
552
+ function unique(values) {
553
+ return [...new Set(values)].sort((a, b) => a.localeCompare(b));
554
+ }
555
+
556
+ // endregion
557
+
558
+ export default parseLangium;
559
+