@reqlan/language 1.6.2 → 1.7.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.
Files changed (55) hide show
  1. package/README.md +6 -0
  2. package/out/generated/ast.d.ts +3 -3
  3. package/out/generated/ast.js +1 -1
  4. package/out/generated/ast.js.map +1 -1
  5. package/out/generated/grammar.js +2 -3318
  6. package/out/generated/grammar.js.map +1 -1
  7. package/out/generated/module.d.ts +2 -2
  8. package/out/generated/module.js +2 -2
  9. package/out/generated/module.js.map +1 -1
  10. package/out/index.d.ts +3 -0
  11. package/out/index.js +3 -0
  12. package/out/index.js.map +1 -1
  13. package/out/reqlan-async-parser.d.ts +37 -0
  14. package/out/reqlan-async-parser.js +117 -0
  15. package/out/reqlan-async-parser.js.map +1 -0
  16. package/out/reqlan-completion-provider.js +1 -1
  17. package/out/reqlan-completion-provider.js.map +1 -1
  18. package/out/reqlan-document-factory.d.ts +16 -0
  19. package/out/reqlan-document-factory.js +50 -0
  20. package/out/reqlan-document-factory.js.map +1 -0
  21. package/out/reqlan-linker.js +2 -2
  22. package/out/reqlan-linker.js.map +1 -1
  23. package/out/reqlan-module.d.ts +2 -1
  24. package/out/reqlan-module.js +12 -2
  25. package/out/reqlan-module.js.map +1 -1
  26. package/out/reqlan-parse-budget.d.ts +50 -0
  27. package/out/reqlan-parse-budget.js +89 -0
  28. package/out/reqlan-parse-budget.js.map +1 -0
  29. package/out/reqlan-parse-worker.d.ts +1 -0
  30. package/out/reqlan-parse-worker.js +34 -0
  31. package/out/reqlan-parse-worker.js.map +1 -0
  32. package/out/reqlan-token-builder.js +102 -25
  33. package/out/reqlan-token-builder.js.map +1 -1
  34. package/out/reqlan-validator.d.ts +9 -1
  35. package/out/reqlan-validator.js +16 -2
  36. package/out/reqlan-validator.js.map +1 -1
  37. package/out/reqlan-workspace-manager.d.ts +11 -0
  38. package/out/reqlan-workspace-manager.js +24 -0
  39. package/out/reqlan-workspace-manager.js.map +1 -0
  40. package/package.json +1 -1
  41. package/src/generated/ast.ts +3 -3
  42. package/src/generated/grammar.ts +2 -3318
  43. package/src/generated/module.ts +2 -2
  44. package/src/index.ts +3 -0
  45. package/src/reqlan-async-parser.ts +178 -0
  46. package/src/reqlan-completion-provider.ts +1 -1
  47. package/src/reqlan-document-factory.ts +75 -0
  48. package/src/reqlan-linker.ts +4 -2
  49. package/src/reqlan-module.ts +15 -3
  50. package/src/reqlan-parse-budget.ts +155 -0
  51. package/src/reqlan-parse-worker.ts +35 -0
  52. package/src/reqlan-token-builder.ts +122 -25
  53. package/src/reqlan-validator.ts +22 -1
  54. package/src/reqlan-workspace-manager.ts +38 -0
  55. package/src/reqlan.langium +3 -1
@@ -108,7 +108,15 @@ const markdownLink = (text: string, offset: number): RegExpExecArray | null => {
108
108
  return null;
109
109
  };
110
110
 
111
- const topLevelBlockOpener = /(?:^|\n)[ \t]*(?:[A-Za-z_.][\w.-]*|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')\s*$/;
111
+ const blockOpenerLine = /^[ \t]*(?:[A-Za-z_.][\w.-]*|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')\s*$/;
112
+
113
+ function lineStartOffset(text: string, offset: number): number {
114
+ return text.lastIndexOf('\n', offset - 1) + 1;
115
+ }
116
+
117
+ function textBeforeOnLine(text: string, offset: number): string {
118
+ return text.slice(lineStartOffset(text, offset), offset).replace(/[ \t]+$/, '');
119
+ }
112
120
 
113
121
  function isEscapedAt(text: string, offset: number): boolean {
114
122
  let backslashes = 0;
@@ -125,9 +133,11 @@ function isStructuralOpenBraceAtDepth(text: string, offset: number, depth: numbe
125
133
  if (isEscapedAt(text, offset)) {
126
134
  return false;
127
135
  }
128
- const before = text.slice(0, offset).replace(/[ \t]+$/, '');
136
+ const before = textBeforeOnLine(text, offset);
129
137
 
130
- if (depth === 0 && topLevelBlockOpener.test(before)) {
138
+ // Top-level named blocks (`name {`) and nameless recoverable blocks (`{` alone).
139
+ // Leading indentation is allowed — top-level ideas are often indented in fixtures.
140
+ if (depth === 0 && (blockOpenerLine.test(before) || (isLineStartAt(text, offset) && before.trim().length === 0))) {
131
141
  return true;
132
142
  }
133
143
  if (/@[A-Za-z_][\w-]*(?::)?\s*$/.test(before)) {
@@ -137,8 +147,7 @@ function isStructuralOpenBraceAtDepth(text: string, offset: number, depth: numbe
137
147
  return true;
138
148
  }
139
149
  if (depth >= 1 && /[A-Za-z_][\w-]*\s*$/.test(before) && !isLineStartAt(text, offset)) {
140
- const after = text.slice(offset + 1);
141
- return /^[ \t]*(?:\r?\n|$)/.test(after);
150
+ return restOfLineIsBlank(text, offset);
142
151
  }
143
152
  return false;
144
153
  }
@@ -156,12 +165,10 @@ function isStructuralCloseBraceAt(text: string, offset: number): boolean {
156
165
  if (isEscapedAt(text, offset)) {
157
166
  return false;
158
167
  }
159
- const after = text.slice(offset + 1);
160
- const restOfLine = after.split(/\r?\n/, 1)[0] ?? '';
161
- if (restOfLine.trim().length !== 0) {
168
+ if (!restOfLineIsBlank(text, offset)) {
162
169
  return false;
163
170
  }
164
- const { structuralDepth, proseDepth } = scanBraceState(text, offset);
171
+ const { structuralDepth, proseDepth } = braceStateBefore(text, offset);
165
172
  if (structuralDepth <= 0) {
166
173
  return false;
167
174
  }
@@ -176,16 +183,84 @@ interface BraceScanState {
176
183
  proseDepth: number;
177
184
  }
178
185
 
179
- function scanBraceState(text: string, offset: number): BraceScanState {
186
+ /**
187
+ * Sparse depth timeline: event `i` applies for offsets in
188
+ * [offsets[i], offsets[i + 1]). Avoids O(n) Int32Arrays on every re-lex.
189
+ */
190
+ interface BraceScanCache {
191
+ text: string;
192
+ offsets: number[];
193
+ structuralDepth: number[];
194
+ proseDepth: number[];
195
+ }
196
+
197
+ let braceScanCache: BraceScanCache | undefined;
198
+
199
+ function fenceEndAfter(text: string, openOffset: number): number {
200
+ // Match CODE_FENCE: ```…``` including optional body; return index after closing fence.
201
+ if (
202
+ text.charCodeAt(openOffset) !== 96
203
+ || text.charCodeAt(openOffset + 1) !== 96
204
+ || text.charCodeAt(openOffset + 2) !== 96
205
+ ) {
206
+ return openOffset;
207
+ }
208
+ const afterOpen = openOffset + 3;
209
+ const firstNewline = text.indexOf('\n', afterOpen);
210
+ if (firstNewline < 0) {
211
+ const sameLineClose = text.indexOf('```', afterOpen);
212
+ return sameLineClose < 0 ? text.length : sameLineClose + 3;
213
+ }
214
+ const close = text.indexOf('```', firstNewline + 1);
215
+ return close < 0 ? text.length : close + 3;
216
+ }
217
+
218
+ function restOfLineIsBlank(text: string, offset: number): boolean {
219
+ for (let index = offset + 1; index < text.length; index++) {
220
+ const char = text[index];
221
+ if (char === '\n' || char === '\r') {
222
+ return true;
223
+ }
224
+ if (char !== ' ' && char !== '\t') {
225
+ return false;
226
+ }
227
+ }
228
+ return true;
229
+ }
230
+
231
+ function buildBraceScanCache(text: string): BraceScanCache {
180
232
  let structuralDepth = 0;
181
233
  const proseDepthByStructural: number[] = [0];
234
+ const offsets = [0];
235
+ const structuralDepthAt = [0];
236
+ const proseDepthAtOffsets = [0];
182
237
 
183
238
  const proseDepthAt = (): number => proseDepthByStructural[structuralDepth] ?? 0;
184
239
  const setProseDepth = (value: number): void => {
185
240
  proseDepthByStructural[structuralDepth] = value;
186
241
  };
242
+ const recordStateFrom = (offset: number): void => {
243
+ const last = offsets.length - 1;
244
+ if (offsets[last] === offset) {
245
+ structuralDepthAt[last] = structuralDepth;
246
+ proseDepthAtOffsets[last] = proseDepthAt();
247
+ return;
248
+ }
249
+ offsets.push(offset);
250
+ structuralDepthAt.push(structuralDepth);
251
+ proseDepthAtOffsets.push(proseDepthAt());
252
+ };
187
253
 
188
- for (let index = 0; index < offset; index++) {
254
+ for (let index = 0; index < text.length; index++) {
255
+ // Fenced snippets are opaque to the parser; braces inside must not change depth.
256
+ if (
257
+ text.charCodeAt(index) === 96
258
+ && text.charCodeAt(index + 1) === 96
259
+ && text.charCodeAt(index + 2) === 96
260
+ ) {
261
+ index = fenceEndAfter(text, index) - 1;
262
+ continue;
263
+ }
189
264
  const char = text[index];
190
265
  if (char === '{') {
191
266
  if (isEscapedAt(text, index)) {
@@ -197,6 +272,7 @@ function scanBraceState(text: string, offset: number): BraceScanState {
197
272
  } else {
198
273
  setProseDepth(proseDepthAt() + 1);
199
274
  }
275
+ recordStateFrom(index + 1);
200
276
  continue;
201
277
  }
202
278
  if (char !== '}') {
@@ -205,23 +281,24 @@ function scanBraceState(text: string, offset: number): BraceScanState {
205
281
  if (isEscapedAt(text, index)) {
206
282
  continue;
207
283
  }
208
- const after = text.slice(index + 1);
209
- const restOfLine = after.split(/\r?\n/, 1)[0] ?? '';
210
- const atEndOfLine = restOfLine.trim().length === 0;
284
+ const atEndOfLine = restOfLineIsBlank(text, index);
211
285
  if (!atEndOfLine) {
212
286
  if (proseDepthAt() > 0) {
213
287
  setProseDepth(proseDepthAt() - 1);
288
+ recordStateFrom(index + 1);
214
289
  }
215
290
  continue;
216
291
  }
217
292
  if (structuralDepth <= 0) {
218
293
  if (proseDepthAt() > 0) {
219
294
  setProseDepth(proseDepthAt() - 1);
295
+ recordStateFrom(index + 1);
220
296
  }
221
297
  continue;
222
298
  }
223
299
  if (isLineStartAt(text, index)) {
224
300
  structuralDepth--;
301
+ recordStateFrom(index + 1);
225
302
  continue;
226
303
  }
227
304
  if (proseDepthAt() > 0) {
@@ -229,20 +306,41 @@ function scanBraceState(text: string, offset: number): BraceScanState {
229
306
  } else {
230
307
  structuralDepth--;
231
308
  }
309
+ recordStateFrom(index + 1);
232
310
  }
233
311
 
312
+ return { text, offsets, structuralDepth: structuralDepthAt, proseDepth: proseDepthAtOffsets };
313
+ }
314
+
315
+ function braceStateAtOffset(cache: BraceScanCache, offset: number): BraceScanState {
316
+ const { offsets, structuralDepth, proseDepth } = cache;
317
+ let low = 0;
318
+ let high = offsets.length - 1;
319
+ while (low <= high) {
320
+ const mid = (low + high) >> 1;
321
+ if (offsets[mid]! <= offset) {
322
+ low = mid + 1;
323
+ } else {
324
+ high = mid - 1;
325
+ }
326
+ }
327
+ const index = Math.max(0, high);
234
328
  return {
235
- structuralDepth,
236
- proseDepth: proseDepthAt()
329
+ structuralDepth: structuralDepth[index] ?? 0,
330
+ proseDepth: proseDepth[index] ?? 0
237
331
  };
238
332
  }
239
333
 
240
- function scanStructuralBraceDepth(text: string, offset: number): number {
241
- return scanBraceState(text, offset).structuralDepth;
334
+ function braceStateBefore(text: string, offset: number): BraceScanState {
335
+ if (braceScanCache?.text !== text) {
336
+ braceScanCache = buildBraceScanCache(text);
337
+ }
338
+ const boundedOffset = Math.max(0, Math.min(offset, text.length));
339
+ return braceStateAtOffset(braceScanCache, boundedOffset);
242
340
  }
243
341
 
244
342
  function isStructuralOpenBraceAt(text: string, offset: number): boolean {
245
- return isStructuralOpenBraceAtDepth(text, offset, scanStructuralBraceDepth(text, offset));
343
+ return isStructuralOpenBraceAtDepth(text, offset, braceStateBefore(text, offset).structuralDepth);
246
344
  }
247
345
 
248
346
  const structuralOpenBrace = (text: string, offset: number): RegExpExecArray | null => {
@@ -274,7 +372,7 @@ const proseCloseBrace = (text: string, offset: number): RegExpExecArray | null =
274
372
  };
275
373
 
276
374
  function braceDepthBefore(text: string, offset: number): number {
277
- return scanStructuralBraceDepth(text, offset);
375
+ return braceStateBefore(text, offset).structuralDepth;
278
376
  }
279
377
 
280
378
  function isLineStartAt(text: string, offset: number): boolean {
@@ -287,15 +385,14 @@ function isLineStartAt(text: string, offset: number): boolean {
287
385
 
288
386
  function isStringLiteralContext(text: string, offset: number): boolean {
289
387
  const depth = braceDepthBefore(text, offset);
290
- const trimmed = text.slice(0, offset).replace(/[ \t]+$/, '');
388
+ const trimmed = textBeforeOnLine(text, offset);
291
389
  if (depth === 0 && /\b(?:from|import)\s*$/.test(trimmed)) {
292
390
  return true;
293
391
  }
294
392
  if (/\[\s*$/.test(trimmed)) {
295
393
  return true;
296
394
  }
297
- const lineStart = trimmed.lastIndexOf('\n') + 1;
298
- if (trimmed.slice(lineStart).length === 0 && depth === 0) {
395
+ if (trimmed.length === 0 && depth === 0) {
299
396
  return true;
300
397
  }
301
398
  return false;
@@ -330,7 +427,7 @@ function topLevelImportKeyword(text: string, offset: number): RegExpExecArray |
330
427
  if (isLineStartAt(text, offset)) {
331
428
  return makeMatch(text, offset, 6);
332
429
  }
333
- const before = text.slice(0, offset).replace(/[ \t]+$/, '');
430
+ const before = textBeforeOnLine(text, offset);
334
431
  if (/(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')$/.test(before)) {
335
432
  return makeMatch(text, offset, 6);
336
433
  }
@@ -348,7 +445,7 @@ function topLevelAsKeyword(text: string, offset: number): RegExpExecArray | null
348
445
  if (braceDepthBefore(text, offset) !== 0) {
349
446
  return null;
350
447
  }
351
- const before = text.slice(0, offset).replace(/[ \t]+$/, '');
448
+ const before = textBeforeOnLine(text, offset);
352
449
  if (/(?:[_a-zA-Z][\w-]*|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')$/.test(before)) {
353
450
  return makeMatch(text, offset, 2);
354
451
  }
@@ -5,7 +5,9 @@ import {
5
5
  isFromImport,
6
6
  isIdea,
7
7
  isInvalidFromImport,
8
+ isModel,
8
9
  isOneLinerIdea,
10
+ type AnonymousBlock,
9
11
  type Model
10
12
  } from './generated/ast.js';
11
13
  import {
@@ -27,12 +29,14 @@ import { unquoteReqlanString } from './reqlan-quoted-strings.js';
27
29
  * rq:["../../../reqlan rq/extension/language-support/features-imports.rq".import_does_not_exist_error]
28
30
  * rq:["../../../reqlan rq/language/imports.rq".import_error_recovery]
29
31
  * rq:["../../../reqlan rq/language/imports.rq".import_tokenisation]
32
+ * rq:["../../../reqlan rq/language/syntax.rq".no_name_idea_safe_warning]
30
33
  */
31
34
  export function registerValidationChecks(services: ReqlanServices) {
32
35
  const registry = services.validation.ValidationRegistry;
33
36
  const validator = services.validation.ReqlanValidator;
34
37
  const checks: ValidationChecks<ReqlanAstType> = {
35
- Model: validator.checkModelDuplicates
38
+ Model: validator.checkModelDuplicates,
39
+ AnonymousBlock: validator.checkNamelessIdeaBlock
36
40
  };
37
41
  registry.register(checks, validator);
38
42
  }
@@ -42,6 +46,7 @@ export function registerValidationChecks(services: ReqlanServices) {
42
46
  * rq:["../../../reqlan rq/extension/language-support/features-imports.rq".import_does_not_exist_error]
43
47
  * rq:["../../../reqlan rq/language/imports.rq".import_error_recovery]
44
48
  * rq:["../../../reqlan rq/language/imports.rq".import_tokenisation]
49
+ * rq:["../../../reqlan rq/language/syntax.rq".no_name_idea_safe_warning]
45
50
  */
46
51
  export class ReqlanValidator {
47
52
 
@@ -55,6 +60,22 @@ export class ReqlanValidator {
55
60
  this.checkFileReferenceTargets(model, accept);
56
61
  }
57
62
 
63
+ /**
64
+ * Top-level nameless `{ ... }` blocks parse so the rest of the file stays alive,
65
+ * but they need a name to be addressable ideas.
66
+ * List-item anonymous blocks remain valid (see lists).
67
+ */
68
+ checkNamelessIdeaBlock(block: AnonymousBlock, accept: ValidationAcceptor): void {
69
+ if (!isModel(block.$container)) {
70
+ return;
71
+ }
72
+ accept(
73
+ 'warning',
74
+ 'Nameless idea block: add a name before `{` (for example `my_idea { ... }`). The block was kept so the rest of the file still parses.',
75
+ { node: block }
76
+ );
77
+ }
78
+
58
79
  /**
59
80
  * Local diagnostics for recoverable but invalid import shapes.
60
81
  * Keeps the rest of the file parseable — see import_error_recovery.
@@ -0,0 +1,38 @@
1
+ import {
2
+ DefaultWorkspaceManager,
3
+ type LangiumDocument,
4
+ type LangiumSharedCoreServices,
5
+ type Stream,
6
+ type URI
7
+ } from 'langium';
8
+
9
+ /**
10
+ * Loads workspace documents independently so one catastrophic parse failure
11
+ * (for example Chevrotain recovery stack overflow) cannot abort LSP workspace init.
12
+ * rq:["../../../reqlan rq/language/syntax.rq".no_name_idea_safe_warning]
13
+ * rq:["../../../reqlan rq/language/parser_lexer.rq".parse_budget_timeout]
14
+ */
15
+ export class ReqlanWorkspaceManager extends DefaultWorkspaceManager {
16
+ constructor(services: LangiumSharedCoreServices) {
17
+ super(services);
18
+ }
19
+
20
+ protected override async loadWorkspaceDocuments(
21
+ uris: Stream<URI>,
22
+ collector: (document: LangiumDocument) => void
23
+ ): Promise<void> {
24
+ await Promise.all(
25
+ uris.map(async uri => {
26
+ try {
27
+ const document = await this.langiumDocuments.getOrCreateDocument(uri);
28
+ collector(document);
29
+ } catch (error) {
30
+ console.error(
31
+ `[reqlan] Skipping document that failed to load during workspace init: ${uri.toString()}`,
32
+ error
33
+ );
34
+ }
35
+ })
36
+ );
37
+ }
38
+ }
@@ -17,8 +17,10 @@ entry Model:
17
17
  (NL* elements+=TopLevelElement)*
18
18
  NL*;
19
19
 
20
+ // Nameless `{ ... }` at top level is recoverable (no_name_idea_safe_warning):
21
+ // parse it, warn, and keep later ideas/imports intact — do not abort the file.
20
22
  TopLevelElement:
21
- Idea | IdeaSet | OneLinerIdea;
23
+ Idea | IdeaSet | OneLinerIdea | AnonymousBlock;
22
24
 
23
25
  // ---------------------------------------------------------------------------
24
26
  // idea_name, one_liner_idea, block_idea, ideaset