@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.
- package/README.md +6 -0
- package/out/generated/ast.d.ts +3 -3
- package/out/generated/ast.js +1 -1
- package/out/generated/ast.js.map +1 -1
- package/out/generated/grammar.js +2 -3318
- package/out/generated/grammar.js.map +1 -1
- package/out/generated/module.d.ts +2 -2
- package/out/generated/module.js +2 -2
- package/out/generated/module.js.map +1 -1
- package/out/index.d.ts +3 -0
- package/out/index.js +3 -0
- package/out/index.js.map +1 -1
- package/out/reqlan-async-parser.d.ts +37 -0
- package/out/reqlan-async-parser.js +117 -0
- package/out/reqlan-async-parser.js.map +1 -0
- package/out/reqlan-completion-provider.js +1 -1
- package/out/reqlan-completion-provider.js.map +1 -1
- package/out/reqlan-document-factory.d.ts +16 -0
- package/out/reqlan-document-factory.js +50 -0
- package/out/reqlan-document-factory.js.map +1 -0
- package/out/reqlan-linker.js +2 -2
- package/out/reqlan-linker.js.map +1 -1
- package/out/reqlan-module.d.ts +2 -1
- package/out/reqlan-module.js +12 -2
- package/out/reqlan-module.js.map +1 -1
- package/out/reqlan-parse-budget.d.ts +50 -0
- package/out/reqlan-parse-budget.js +89 -0
- package/out/reqlan-parse-budget.js.map +1 -0
- package/out/reqlan-parse-worker.d.ts +1 -0
- package/out/reqlan-parse-worker.js +34 -0
- package/out/reqlan-parse-worker.js.map +1 -0
- package/out/reqlan-token-builder.js +102 -25
- package/out/reqlan-token-builder.js.map +1 -1
- package/out/reqlan-validator.d.ts +9 -1
- package/out/reqlan-validator.js +16 -2
- package/out/reqlan-validator.js.map +1 -1
- package/out/reqlan-workspace-manager.d.ts +11 -0
- package/out/reqlan-workspace-manager.js +24 -0
- package/out/reqlan-workspace-manager.js.map +1 -0
- package/package.json +1 -1
- package/src/generated/ast.ts +3 -3
- package/src/generated/grammar.ts +2 -3318
- package/src/generated/module.ts +2 -2
- package/src/index.ts +3 -0
- package/src/reqlan-async-parser.ts +178 -0
- package/src/reqlan-completion-provider.ts +1 -1
- package/src/reqlan-document-factory.ts +75 -0
- package/src/reqlan-linker.ts +4 -2
- package/src/reqlan-module.ts +15 -3
- package/src/reqlan-parse-budget.ts +155 -0
- package/src/reqlan-parse-worker.ts +35 -0
- package/src/reqlan-token-builder.ts +122 -25
- package/src/reqlan-validator.ts +22 -1
- package/src/reqlan-workspace-manager.ts +38 -0
- package/src/reqlan.langium +3 -1
package/src/generated/module.ts
CHANGED
|
@@ -11,14 +11,14 @@ export const ReqlanLanguageMetaData = {
|
|
|
11
11
|
languageId: 'reqlan',
|
|
12
12
|
fileExtensions: ['.rq'],
|
|
13
13
|
caseInsensitive: false,
|
|
14
|
-
mode: '
|
|
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: '
|
|
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
|
+
}
|
package/src/reqlan-linker.ts
CHANGED
|
@@ -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 =>
|
|
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;
|
package/src/reqlan-module.ts
CHANGED
|
@@ -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
|
+
});
|