@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
@@ -11,14 +11,14 @@ export const ReqlanLanguageMetaData = {
11
11
  languageId: 'reqlan',
12
12
  fileExtensions: ['.rq'],
13
13
  caseInsensitive: false,
14
- mode: 'development'
14
+ mode: 'production'
15
15
  } as const satisfies LanguageMetaData;
16
16
 
17
17
  export const ReqlanCommentLanguageMetaData = {
18
18
  languageId: 'reqlan-comment',
19
19
  fileExtensions: [],
20
20
  caseInsensitive: false,
21
- mode: 'development'
21
+ mode: 'production'
22
22
  } as const satisfies LanguageMetaData;
23
23
 
24
24
  export const ReqlanGeneratedSharedModule: Module<LangiumSharedCoreServices, LangiumGeneratedSharedCoreServices> = {
package/src/index.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  export * from './reqlan-module.js';
2
+ export * from './reqlan-parse-budget.js';
3
+ export { ReqlanAsyncParser, resolveParseWorkerPath } from './reqlan-async-parser.js';
4
+ export { ReqlanLangiumDocumentFactory } from './reqlan-document-factory.js';
2
5
  export * from './reqlan-validator.js';
3
6
  export * from './reqlan-comment-resolver.js';
4
7
  export * from './reqlan-ignore-error.js';
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Per-file parse budget via Langium's worker-thread async parser.
3
+ * Happy path is in-process sync parse; workers enforce a killable wall-clock budget
4
+ * for large files, sticky timeout escalation, or explicit force.
5
+ * rq:["../../../reqlan rq/language/parser_lexer.rq".parse_budget_timeout]
6
+ */
7
+ import { existsSync } from 'node:fs';
8
+ import { dirname, join } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import {
11
+ type AstNode,
12
+ type LangiumCoreServices,
13
+ type LangiumParser
14
+ } from 'langium';
15
+ import { WorkerThreadAsyncParser } from 'langium/node';
16
+ import {
17
+ CancellationToken,
18
+ CancellationTokenSource
19
+ } from 'vscode-languageserver';
20
+ import {
21
+ createIncompleteParseResult,
22
+ DEFAULT_PARSE_BUDGET_MS,
23
+ DEFAULT_WORKER_PARSE_THRESHOLD_CHARS,
24
+ type ParseWorkerMode,
25
+ type ReqlanParseResult,
26
+ shouldEscalateToParseWorker
27
+ } from './reqlan-parse-budget.js';
28
+
29
+ export interface ReqlanAsyncParserOptions {
30
+ timeoutMs?: number;
31
+ /** Override worker script path (tests). */
32
+ workerPath?: string;
33
+ /**
34
+ * `auto` (default): sync below size threshold; worker when escalated.
35
+ * `true` / `false`: always / never use workers.
36
+ */
37
+ useWorker?: ParseWorkerMode;
38
+ /** Characters at or above which `auto` mode uses a worker. */
39
+ workerThresholdChars?: number;
40
+ /** Worker pool size (Langium default is 8; keep small for startup). */
41
+ threadCount?: number;
42
+ }
43
+
44
+ export interface ReqlanParseCallOptions {
45
+ /** Force the killable worker path for this call (sticky URI escalate, tests). */
46
+ forceWorker?: boolean;
47
+ }
48
+
49
+ /**
50
+ * Directory of this module (or of the CJS bundle that inlined it).
51
+ * Avoid static `import.meta.url`: esbuild format cjs + target es2017 empties it
52
+ * (see reqlan rq/extension/startup-performance.rq invalid_url_activation_failure).
53
+ */
54
+ declare const __dirname: string | undefined;
55
+
56
+ function moduleDirectory(): string {
57
+ if (typeof __dirname === 'string') {
58
+ return __dirname;
59
+ }
60
+ // Native ESM only — hide from esbuild's empty-import-meta rewrite.
61
+ const metaUrl = (0, eval)('import.meta.url') as string;
62
+ return dirname(fileURLToPath(metaUrl));
63
+ }
64
+
65
+ export function resolveParseWorkerPath(explicit?: string): string {
66
+ if (explicit) {
67
+ return explicit;
68
+ }
69
+ const dir = moduleDirectory();
70
+ const jsPath = join(dir, 'reqlan-parse-worker.js');
71
+ if (existsSync(jsPath)) {
72
+ return jsPath;
73
+ }
74
+ const cjsPath = join(dir, 'reqlan-parse-worker.cjs');
75
+ if (existsSync(cjsPath)) {
76
+ return cjsPath;
77
+ }
78
+ return jsPath;
79
+ }
80
+
81
+ export class ReqlanAsyncParser extends WorkerThreadAsyncParser {
82
+ private readonly timeoutMs: number;
83
+ private readonly useWorker: ParseWorkerMode;
84
+ private readonly workerThresholdChars: number;
85
+ private readonly syncParser: LangiumParser;
86
+
87
+ constructor(services: LangiumCoreServices, options: ReqlanAsyncParserOptions = {}) {
88
+ super(services, () => resolveParseWorkerPath(options.workerPath));
89
+ this.syncParser = services.parser.LangiumParser;
90
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_PARSE_BUDGET_MS;
91
+ this.useWorker = options.useWorker ?? 'auto';
92
+ this.workerThresholdChars = options.workerThresholdChars ?? DEFAULT_WORKER_PARSE_THRESHOLD_CHARS;
93
+ this.threadCount = options.threadCount ?? 2;
94
+ // Fail fast after cancel so the budget is close to timeoutMs wall-clock.
95
+ this.terminationDelay = 50;
96
+ }
97
+
98
+ override async parse<T extends AstNode>(
99
+ text: string,
100
+ cancelToken: CancellationToken,
101
+ callOptions: ReqlanParseCallOptions = {}
102
+ ): Promise<ReqlanParseResult<T>> {
103
+ if (cancelToken.isCancellationRequested) {
104
+ return createIncompleteParseResult<T>(this.syncParser, {
105
+ reason: 'failure',
106
+ cause: 'cancelled'
107
+ });
108
+ }
109
+
110
+ const escalate = shouldEscalateToParseWorker(text, {
111
+ useWorker: this.useWorker,
112
+ forceWorker: callOptions.forceWorker,
113
+ thresholdChars: this.workerThresholdChars
114
+ });
115
+ if (!escalate) {
116
+ return this.parseOnThread<T>(text);
117
+ }
118
+
119
+ return this.parseInWorker<T>(text, cancelToken);
120
+ }
121
+
122
+ /** Sync entry used by document factory / tests. */
123
+ parseSync<T extends AstNode>(text: string): ReqlanParseResult<T> {
124
+ return this.parseOnThread<T>(text);
125
+ }
126
+
127
+ private async parseInWorker<T extends AstNode>(
128
+ text: string,
129
+ cancelToken: CancellationToken
130
+ ): Promise<ReqlanParseResult<T>> {
131
+ const timeoutSource = new CancellationTokenSource();
132
+ const timer = setTimeout(() => timeoutSource.cancel(), this.timeoutMs);
133
+ const callerCancel = cancelToken.onCancellationRequested(() => {
134
+ timeoutSource.cancel();
135
+ });
136
+
137
+ try {
138
+ return await super.parse<T>(text, timeoutSource.token) as ReqlanParseResult<T>;
139
+ } catch (error) {
140
+ if (timeoutSource.token.isCancellationRequested) {
141
+ if (cancelToken.isCancellationRequested) {
142
+ return createIncompleteParseResult<T>(this.syncParser, {
143
+ reason: 'failure',
144
+ cause: 'cancelled'
145
+ });
146
+ }
147
+ return createIncompleteParseResult<T>(this.syncParser, {
148
+ reason: 'timeout',
149
+ timeoutMs: this.timeoutMs
150
+ });
151
+ }
152
+ return createIncompleteParseResult<T>(this.syncParser, {
153
+ reason: 'failure',
154
+ cause: error instanceof Error ? error.message : String(error)
155
+ });
156
+ } finally {
157
+ clearTimeout(timer);
158
+ callerCancel.dispose();
159
+ timeoutSource.dispose();
160
+ }
161
+ }
162
+
163
+ private parseOnThread<T extends AstNode>(text: string): ReqlanParseResult<T> {
164
+ try {
165
+ return this.syncParser.parse(text) as ReqlanParseResult<T>;
166
+ } catch (error) {
167
+ return createIncompleteParseResult<T>(this.syncParser, {
168
+ reason: 'failure',
169
+ cause: error instanceof Error ? error.message : String(error)
170
+ });
171
+ }
172
+ }
173
+
174
+ /** Exposed for tests that need the underlying sync parser. */
175
+ get langiumParser(): LangiumParser {
176
+ return this.syncParser;
177
+ }
178
+ }
@@ -124,7 +124,7 @@ export class ReqlanCompletionProvider extends DefaultCompletionProvider {
124
124
  return [];
125
125
  }
126
126
  return model.elements
127
- .filter(element => {
127
+ .filter((element): element is typeof element & { name: string } => {
128
128
  if (refInfo.property === 'ideaset') {
129
129
  return isIdeaSet(element);
130
130
  }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Document factory that never lets a sync lex/parse throw escape into workspace init.
3
+ * Async path uses [ReqlanAsyncParser]: sync fast path by default, killable worker when escalated.
4
+ * rq:["../../../reqlan rq/language/parser_lexer.rq".parse_budget_timeout]
5
+ */
6
+ import {
7
+ DefaultLangiumDocumentFactory,
8
+ type AstNode,
9
+ type LangiumDocument,
10
+ type LangiumSharedCoreServices,
11
+ type ParseResult,
12
+ type ParserOptions,
13
+ URI
14
+ } from 'langium';
15
+ import type { CancellationToken } from 'vscode-languageserver';
16
+ import { CancellationToken as CancelToken } from 'vscode-languageserver';
17
+ import { ReqlanAsyncParser } from './reqlan-async-parser.js';
18
+ import {
19
+ createIncompleteParseResult,
20
+ type ReqlanParseResult
21
+ } from './reqlan-parse-budget.js';
22
+
23
+ export class ReqlanLangiumDocumentFactory extends DefaultLangiumDocumentFactory {
24
+ /** URIs that previously hit the parse budget — keep using a killable worker. */
25
+ private readonly workerEscalateUris = new Set<string>();
26
+
27
+ constructor(services: LangiumSharedCoreServices) {
28
+ super(services);
29
+ }
30
+
31
+ protected override parse<T extends AstNode>(
32
+ uri: URI,
33
+ text: string,
34
+ options?: ParserOptions
35
+ ): ParseResult<T> {
36
+ const services = this.serviceRegistry.getServices(uri);
37
+ try {
38
+ return services.parser.LangiumParser.parse(text, options);
39
+ } catch (error) {
40
+ return createIncompleteParseResult<T>(services.parser.LangiumParser, {
41
+ reason: 'failure',
42
+ cause: error instanceof Error ? error.message : String(error)
43
+ });
44
+ }
45
+ }
46
+
47
+ protected override async parseAsync<T extends AstNode>(
48
+ uri: URI,
49
+ text: string,
50
+ cancellationToken: CancellationToken = CancelToken.None
51
+ ): Promise<ParseResult<T>> {
52
+ const services = this.serviceRegistry.getServices(uri);
53
+ const asyncParser = services.parser.AsyncParser;
54
+ const uriKey = uri.toString();
55
+ const forceWorker = this.workerEscalateUris.has(uriKey);
56
+
57
+ const result = asyncParser instanceof ReqlanAsyncParser
58
+ ? await asyncParser.parse<T>(text, cancellationToken, { forceWorker })
59
+ : await asyncParser.parse<T>(text, cancellationToken);
60
+
61
+ const reqlanResult = result as ReqlanParseResult<T>;
62
+ if (reqlanResult.reqlanIncomplete?.reason === 'timeout') {
63
+ this.workerEscalateUris.add(uriKey);
64
+ } else if (!reqlanResult.reqlanIncomplete) {
65
+ this.workerEscalateUris.delete(uriKey);
66
+ }
67
+
68
+ return result;
69
+ }
70
+
71
+ /** Test helper: expose incomplete marker after async parse. */
72
+ static isIncompleteDocument(document: LangiumDocument): boolean {
73
+ return Boolean((document.parseResult as ReqlanParseResult).reqlanIncomplete);
74
+ }
75
+ }
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import type { AstNode, FileSystemProvider, LangiumDocument, LangiumDocuments, ReferenceInfo } from 'langium';
7
7
  import { AstUtils, DefaultLinker, type DefaultReference } from 'langium';
8
- import { isLocalReference, isModel, isQualifiedReference } from './generated/ast.js';
8
+ import { isIdea, isIdeaSet, isLocalReference, isModel, isOneLinerIdea, isQualifiedReference } from './generated/ast.js';
9
9
  import { isOpaqueFileReferencePath } from './reqlan-file-references.js';
10
10
  import { findNamespaceImportByAlias } from './reqlan-import-bindings.js';
11
11
  import { isResolvableImportPath } from './reqlan-imports.js';
@@ -69,7 +69,9 @@ export class ReqlanLinker extends DefaultLinker {
69
69
  return false;
70
70
  }
71
71
  const name = refInfo.reference.$refText;
72
- if (model.elements.some(element => element.name === name)) {
72
+ if (model.elements.some(element =>
73
+ (isIdea(element) || isOneLinerIdea(element) || isIdeaSet(element)) && element.name === name
74
+ )) {
73
75
  return false;
74
76
  }
75
77
  return findNamespaceImportByAlias(model.imports, name) !== undefined;
@@ -1,4 +1,4 @@
1
- import { type Module, inject } from 'langium';
1
+ import { type Module, inject, type PartialLangiumSharedCoreServices } from 'langium';
2
2
  import { createDefaultModule, createDefaultSharedModule, type DefaultSharedModuleContext, type LangiumServices, type LangiumSharedServices, type PartialLangiumServices } from 'langium/lsp';
3
3
  import { ReqlanGeneratedModule, ReqlanGeneratedSharedModule } from './generated/module.js';
4
4
  import { ReqlanDefinitionProvider } from './reqlan-definition-provider.js';
@@ -18,6 +18,9 @@ import { ReqlanSemanticTokenProvider } from './reqlan-semantic-token-provider.js
18
18
  import { ReqlanTokenBuilder } from './reqlan-token-builder.js';
19
19
  import { registerRqIgnoreErrorFiltering } from './reqlan-ignore-error.js';
20
20
  import { ReqlanValidator, registerValidationChecks } from './reqlan-validator.js';
21
+ import { ReqlanWorkspaceManager } from './reqlan-workspace-manager.js';
22
+ import { ReqlanAsyncParser } from './reqlan-async-parser.js';
23
+ import { ReqlanLangiumDocumentFactory } from './reqlan-document-factory.js';
21
24
 
22
25
  /**
23
26
  * Declaration of custom services - add your own service classes here.
@@ -39,6 +42,13 @@ export type ReqlanServices = LangiumServices & ReqlanAddedServices
39
42
  * declared custom services. The Langium defaults can be partially specified to override only
40
43
  * selected services, while the custom services must be fully specified.
41
44
  */
45
+ export const ReqlanSharedModule: Module<LangiumSharedServices, PartialLangiumSharedCoreServices> = {
46
+ workspace: {
47
+ WorkspaceManager: services => new ReqlanWorkspaceManager(services),
48
+ LangiumDocumentFactory: services => new ReqlanLangiumDocumentFactory(services)
49
+ }
50
+ };
51
+
42
52
  export const ReqlanModule: Module<ReqlanServices, PartialLangiumServices & ReqlanAddedServices> = {
43
53
  validation: {
44
54
  ReqlanValidator: services => new ReqlanValidator(services)
@@ -51,7 +61,8 @@ export const ReqlanModule: Module<ReqlanServices, PartialLangiumServices & Reqla
51
61
  },
52
62
  parser: {
53
63
  GrammarConfig: createReqlanGrammarConfig,
54
- TokenBuilder: () => new ReqlanTokenBuilder()
64
+ TokenBuilder: () => new ReqlanTokenBuilder(),
65
+ AsyncParser: (services: ReqlanServices) => new ReqlanAsyncParser(services)
55
66
  },
56
67
  lsp: {
57
68
  DefinitionProvider: services => new ReqlanDefinitionProvider(services),
@@ -87,7 +98,8 @@ export function createReqlanServices(context: DefaultSharedModuleContext): {
87
98
  } {
88
99
  const shared = inject(
89
100
  createDefaultSharedModule(context),
90
- ReqlanGeneratedSharedModule
101
+ ReqlanGeneratedSharedModule,
102
+ ReqlanSharedModule
91
103
  );
92
104
  const Reqlan = inject(
93
105
  createDefaultModule({ shared }),
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Per-file parse/lex budget: if a document cannot finish within the budget,
3
+ * return an empty model plus warning + error diagnostics instead of hanging the host.
4
+ * rq:["../../../reqlan rq/language/parser_lexer.rq".parse_budget_timeout]
5
+ */
6
+ import type { AstNode, LangiumParser, ParseResult } from 'langium';
7
+
8
+ /** Default wall-clock budget for lex+parse of one file (worker-enforced). */
9
+ export const DEFAULT_PARSE_BUDGET_MS = 8_000;
10
+
11
+ /**
12
+ * Prefer in-process sync parse below this size. Worker threads (killable budget)
13
+ * are reserved for larger inputs, hang-sentinel tests, sticky timeout escalation,
14
+ * or an explicit force.
15
+ */
16
+ export const DEFAULT_WORKER_PARSE_THRESHOLD_CHARS = 64_000;
17
+
18
+ /** Test-only sentinel: a worker that sees this marker hangs until terminated. */
19
+ export const PARSE_HANG_SENTINEL = '__REQLAN_PARSE_HANG__';
20
+
21
+ export const PARSE_TIMEOUT_WARNING =
22
+ 'Parse budget exceeded for this file; semantic features may be incomplete until the file is simplified or the budget is raised.';
23
+
24
+ export function parseTimeoutErrorMessage(timeoutMs: number): string {
25
+ return `Failed to lex/parse this file within ${timeoutMs}ms; left unloaded so the rest of the workspace can continue.`;
26
+ }
27
+
28
+ export function parseFailureErrorMessage(cause: string): string {
29
+ return `Lex/parse aborted for this file (${cause}); left unloaded so the rest of the workspace can continue.`;
30
+ }
31
+
32
+ export type ParseWorkerMode = boolean | 'auto';
33
+
34
+ export interface ParseWorkerEscalationOptions {
35
+ /** `auto` (default): sync below threshold; worker for large / sentinel / force. */
36
+ useWorker?: ParseWorkerMode;
37
+ forceWorker?: boolean;
38
+ thresholdChars?: number;
39
+ }
40
+
41
+ /** Whether this parse should run in a killable worker thread. */
42
+ export function shouldEscalateToParseWorker(
43
+ text: string,
44
+ options: ParseWorkerEscalationOptions = {}
45
+ ): boolean {
46
+ const mode = options.useWorker ?? 'auto';
47
+ if (mode === false) {
48
+ return false;
49
+ }
50
+ if (mode === true || options.forceWorker) {
51
+ return true;
52
+ }
53
+ if (text.includes(PARSE_HANG_SENTINEL)) {
54
+ return true;
55
+ }
56
+ const threshold = options.thresholdChars ?? DEFAULT_WORKER_PARSE_THRESHOLD_CHARS;
57
+ return text.length >= threshold;
58
+ }
59
+
60
+ /** Lexer error plus optional severity used for budget-timeout warnings. */
61
+ export type ReqlanLexerError = ParseResult['lexerErrors'][number] & {
62
+ severity?: 'warning' | 'error' | 'info' | 'hint';
63
+ };
64
+
65
+ export interface ReqlanParseResult<T extends AstNode = AstNode> extends Omit<ParseResult<T>, 'lexerErrors'> {
66
+ lexerErrors: ReqlanLexerError[];
67
+ /** Set when lex/parse did not complete successfully within budget (timeout or crash). */
68
+ reqlanIncomplete?: {
69
+ reason: 'timeout' | 'failure';
70
+ timeoutMs?: number;
71
+ cause?: string;
72
+ };
73
+ }
74
+
75
+ export function isReqlanIncompleteParseResult(
76
+ result: ParseResult
77
+ ): result is ReqlanParseResult {
78
+ return Boolean((result as ReqlanParseResult).reqlanIncomplete);
79
+ }
80
+
81
+ interface PlaceholderToken {
82
+ image: string;
83
+ startOffset: number;
84
+ endOffset: number;
85
+ startLine: number;
86
+ endLine: number;
87
+ startColumn: number;
88
+ endColumn: number;
89
+ tokenTypeIdx: number;
90
+ tokenType: { name: string };
91
+ }
92
+
93
+ function placeholderToken(): PlaceholderToken {
94
+ return {
95
+ image: '',
96
+ startOffset: Number.NaN,
97
+ endOffset: Number.NaN,
98
+ startLine: Number.NaN,
99
+ endLine: Number.NaN,
100
+ startColumn: Number.NaN,
101
+ endColumn: Number.NaN,
102
+ tokenTypeIdx: -1,
103
+ tokenType: { name: 'EOF' }
104
+ };
105
+ }
106
+
107
+ function timeoutParserError(message: string) {
108
+ return {
109
+ name: 'ReqlanParseTimeout',
110
+ message,
111
+ token: placeholderToken(),
112
+ resyncedTokens: [],
113
+ context: {
114
+ ruleStack: [],
115
+ ruleOccurrenceStack: []
116
+ }
117
+ };
118
+ }
119
+
120
+ function timeoutLexerWarning(message: string): ReqlanLexerError {
121
+ return {
122
+ offset: 0,
123
+ line: 1,
124
+ column: 1,
125
+ length: 1,
126
+ message,
127
+ severity: 'warning'
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Empty model with a lexer warning and a parser error so the editor shows both severities.
133
+ */
134
+ export function createIncompleteParseResult<T extends AstNode>(
135
+ parser: LangiumParser,
136
+ options: {
137
+ reason: 'timeout' | 'failure';
138
+ timeoutMs?: number;
139
+ cause?: string;
140
+ }
141
+ ): ReqlanParseResult<T> {
142
+ const empty = parser.parse('') as ReqlanParseResult<T>;
143
+ const errorMessage = options.reason === 'timeout'
144
+ ? parseTimeoutErrorMessage(options.timeoutMs ?? DEFAULT_PARSE_BUDGET_MS)
145
+ : parseFailureErrorMessage(options.cause ?? 'unexpected error');
146
+ // Casts: Chevrotain exception shapes are structural; Langium only needs message + token positions.
147
+ empty.parserErrors = [timeoutParserError(errorMessage) as ReqlanParseResult<T>['parserErrors'][number]];
148
+ empty.lexerErrors = [timeoutLexerWarning(PARSE_TIMEOUT_WARNING)];
149
+ empty.reqlanIncomplete = {
150
+ reason: options.reason,
151
+ timeoutMs: options.timeoutMs,
152
+ cause: options.cause
153
+ };
154
+ return empty;
155
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Langium worker-thread parse entry: sync lex/parse, dehydrate, postMessage.
3
+ * The parent enforces the wall-clock budget via worker.terminate().
4
+ * rq:["../../../reqlan rq/language/parser_lexer.rq".parse_budget_timeout]
5
+ */
6
+ import { parentPort } from 'node:worker_threads';
7
+ import { EmptyFileSystem } from 'langium';
8
+ import { createReqlanServices } from './reqlan-module.js';
9
+ import { PARSE_HANG_SENTINEL } from './reqlan-parse-budget.js';
10
+
11
+ if (!parentPort) {
12
+ throw new Error('reqlan-parse-worker must run as a worker thread');
13
+ }
14
+
15
+ const { Reqlan } = createReqlanServices(EmptyFileSystem);
16
+ const parser = Reqlan.parser.LangiumParser;
17
+ const hydrator = Reqlan.serializer.Hydrator;
18
+
19
+ parentPort.on('message', (text: unknown) => {
20
+ const input = typeof text === 'string' ? text : '';
21
+ if (input.includes(PARSE_HANG_SENTINEL)) {
22
+ // Intentional hang for tests — parent terminates on budget expiry.
23
+ for (;;) {
24
+ /* spin */
25
+ }
26
+ }
27
+ try {
28
+ const result = parser.parse(input);
29
+ parentPort!.postMessage(hydrator.dehydrate(result));
30
+ } catch (error) {
31
+ // Surface as worker 'error' so ParserWorker rejects instead of hydrating garbage.
32
+ const message = error instanceof Error ? error.message : String(error);
33
+ throw new Error(`reqlan parse worker failed: ${message}`);
34
+ }
35
+ });