Skip to content

Commit c87b314

Browse files
authored
Fix async parser implementation (#1389)
1 parent 24e3807 commit c87b314

File tree

4 files changed

+107
-36
lines changed

4 files changed

+107
-36
lines changed

packages/langium/src/parser/async-parser.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,8 @@ export abstract class AbstractThreadedAsyncParser implements AsyncParser {
9292
}, this.terminationDelay);
9393
});
9494
worker.parse(text).then(result => {
95-
result.value = this.hydrator.hydrate(result.value);
96-
deferred.resolve(result as ParseResult<T>);
95+
const hydrated = this.hydrator.hydrate<T>(result);
96+
deferred.resolve(hydrated);
9797
}).catch(err => {
9898
deferred.reject(err);
9999
}).finally(() => {

packages/langium/src/serializer/hydrator.ts

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,25 +12,26 @@ import { isAbstractElement, type AbstractElement, type Grammar } from '../langua
1212
import type { Linker } from '../references/linker.js';
1313
import type { Lexer } from '../parser/lexer.js';
1414
import type { LangiumCoreServices } from '../services.js';
15-
import type { Reference, AstNode, CstNode, LeafCstNode, GenericAstNode, Mutable } from '../syntax-tree.js';
15+
import type { ParseResult } from '../parser/langium-parser.js';
16+
import type { Reference, AstNode, CstNode, LeafCstNode, GenericAstNode, Mutable, RootCstNode } from '../syntax-tree.js';
1617
import { isRootCstNode, isCompositeCstNode, isLeafCstNode, isAstNode, isReference } from '../syntax-tree.js';
1718
import { streamAst } from '../utils/ast-utils.js';
1819
import { BiMap } from '../utils/collections.js';
1920
import { streamCst } from '../utils/cst-utils.js';
2021

2122
/**
22-
* The hydrator service is responsible for allowing AST nodes to be sent across worker threads.
23+
* The hydrator service is responsible for allowing AST parse results to be sent across worker threads.
2324
*/
2425
export interface Hydrator {
2526
/**
26-
* Converts an AST node to a plain object. The resulting object can be sent across worker threads.
27+
* Converts a parse result to a plain object. The resulting object can be sent across worker threads.
2728
*/
28-
dehydrate(node: AstNode): object;
29+
dehydrate(result: ParseResult<AstNode>): ParseResult<object>;
2930
/**
30-
* Converts a plain object to an AST node. The resulting AST node can be used in the main thread.
31-
* Calling this method on non-plain objects will result in undefined behavior.
31+
* Converts a plain object to a parse result. The included AST node can then be used in the main thread.
32+
* Calling this method on objects that have not been dehydrated first will result in undefined behavior.
3233
*/
33-
hydrate(node: object): AstNode;
34+
hydrate<T extends AstNode = AstNode>(result: ParseResult<object>): ParseResult<T>;
3435
}
3536

3637
export interface DehydrateContext {
@@ -58,8 +59,14 @@ export class DefaultHydrator implements Hydrator {
5859
this.linker = services.references.Linker;
5960
}
6061

61-
dehydrate(node: AstNode): object {
62-
return this.dehydrateAstNode(node, this.createDehyrationContext(node));
62+
dehydrate(result: ParseResult<AstNode>): ParseResult<object> {
63+
return {
64+
// We need to create shallow copies of the errors
65+
// The original errors inherit from the `Error` class, which is not transferable across worker threads
66+
lexerErrors: result.lexerErrors.map(e => ({ ...e })),
67+
parserErrors: result.parserErrors.map(e => ({ ...e })),
68+
value: this.dehydrateAstNode(result.value, this.createDehyrationContext(result.value))
69+
};
6370
}
6471

6572
protected createDehyrationContext(node: AstNode): DehydrateContext {
@@ -128,6 +135,7 @@ export class DefaultHydrator implements Hydrator {
128135
if (isRootCstNode(node)) {
129136
cstNode.fullText = node.fullText;
130137
} else {
138+
// Note: This returns undefined for hidden nodes (i.e. comments)
131139
cstNode.grammarSource = this.getGrammarElementId(node.grammarSource);
132140
}
133141
cstNode.hidden = node.hidden;
@@ -146,12 +154,17 @@ export class DefaultHydrator implements Hydrator {
146154
return cstNode;
147155
}
148156

149-
hydrate(node: object): AstNode {
157+
hydrate<T extends AstNode = AstNode>(result: ParseResult<object>): ParseResult<T> {
158+
const node = result.value;
150159
const context = this.createHydrationContext(node);
151160
if ('$cstNode' in node) {
152161
this.hydrateCstNode(node.$cstNode, context);
153162
}
154-
return this.hydrateAstNode(node, context);
163+
return {
164+
lexerErrors: result.lexerErrors,
165+
parserErrors: result.parserErrors,
166+
value: this.hydrateAstNode(node, context) as T
167+
};
155168
}
156169

157170
protected createHydrationContext(node: any): HydrateContext {
@@ -160,18 +173,21 @@ export class DefaultHydrator implements Hydrator {
160173
for (const astNode of streamAst(node)) {
161174
astNodes.set(astNode, {} as AstNode);
162175
}
176+
let root: RootCstNode;
163177
if (node.$cstNode) {
164178
for (const cstNode of streamCst(node.$cstNode)) {
165-
let cst: CstNode | undefined;
179+
let cst: Mutable<CstNode> | undefined;
166180
if ('fullText' in cstNode) {
167181
cst = new RootCstNodeImpl(cstNode.fullText as string);
182+
root = cst as RootCstNode;
168183
} else if ('content' in cstNode) {
169184
cst = new CompositeCstNodeImpl();
170185
} else if ('tokenType' in cstNode) {
171186
cst = this.hydrateCstLeafNode(cstNode);
172187
}
173188
if (cst) {
174189
cstNodes.set(cstNode, cst);
190+
cst.root = root!;
175191
}
176192
}
177193
}
@@ -198,15 +214,15 @@ export class DefaultHydrator implements Hydrator {
198214
astNode[name] = arr;
199215
for (const item of value) {
200216
if (isAstNode(item)) {
201-
arr.push(this.setParent(this.hydrate(item), astNode));
217+
arr.push(this.setParent(this.hydrateAstNode(item, context), astNode));
202218
} else if (isReference(item)) {
203219
arr.push(this.hydrateReference(item, astNode, name, context));
204220
} else {
205221
arr.push(item);
206222
}
207223
}
208224
} else if (isAstNode(value)) {
209-
astNode[name] = this.setParent(this.hydrate(value), astNode);
225+
astNode[name] = this.setParent(this.hydrateAstNode(value, context), astNode);
210226
} else if (isReference(value)) {
211227
astNode[name] = this.hydrateReference(value, astNode, name, context);
212228
} else if (value !== undefined) {
@@ -272,11 +288,11 @@ export class DefaultHydrator implements Hydrator {
272288
return this.lexer.definition[name];
273289
}
274290

275-
protected getGrammarElementId(node: AbstractElement): number {
291+
protected getGrammarElementId(node: AbstractElement): number | undefined {
276292
if (this.grammarElementIdMap.size === 0) {
277293
this.createGrammarElementIdMap();
278294
}
279-
return this.grammarElementIdMap.get(node) ?? -1;
295+
return this.grammarElementIdMap.get(node);
280296
}
281297

282298
protected getGrammarElement(id: number): AbstractElement {

packages/langium/test/parser/worker-thread-async-parser.test.ts

Lines changed: 71 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import { describe, expect, test } from 'vitest';
88
import { WorkerThreadAsyncParser } from 'langium/node';
99
import { createLangiumGrammarServices } from 'langium/grammar';
1010
import type { Grammar, LangiumCoreServices, ParseResult } from 'langium';
11-
import { EmptyFileSystem, GrammarUtils, isOperationCancelled } from 'langium';
11+
import type { LangiumServices } from 'langium/lsp';
12+
import { EmptyFileSystem, GrammarUtils, CstUtils, GrammarAST, isOperationCancelled } from 'langium';
1213
import { CancellationToken, CancellationTokenSource } from 'vscode-languageserver';
1314
import { fail } from 'node:assert';
1415
import { fileURLToPath } from 'node:url';
@@ -17,18 +18,18 @@ class TestAsyncParser extends WorkerThreadAsyncParser {
1718
constructor(services: LangiumCoreServices) {
1819
super(services, () => fileURLToPath(new URL('.', import.meta.url)) + '/worker-thread.js');
1920
}
21+
setThreadCount(threadCount: number): void {
22+
this.threadCount = threadCount;
23+
}
2024
}
2125

2226
describe('WorkerThreadAsyncParser', () => {
2327

2428
test('performs async parsing in parallel', async () => {
25-
const services = createLangiumGrammarServices(EmptyFileSystem, undefined, {
26-
parser: {
27-
AsyncParser: (services) => new TestAsyncParser(services)
28-
}
29-
}).grammar;
29+
const services = getServices();
3030
const file = createLargeFile(10);
31-
const asyncParser = services.parser.AsyncParser;
31+
const asyncParser = services.parser.AsyncParser as TestAsyncParser;
32+
asyncParser.setThreadCount(4);
3233
const promises: Array<Promise<ParseResult<Grammar>>> = [];
3334
for (let i = 0; i < 16; i++) {
3435
promises.push(asyncParser.parse<Grammar>(file, CancellationToken.None));
@@ -41,11 +42,7 @@ describe('WorkerThreadAsyncParser', () => {
4142
});
4243

4344
test('async parsing can be cancelled', async () => {
44-
const services = createLangiumGrammarServices(EmptyFileSystem, undefined, {
45-
parser: {
46-
AsyncParser: (services) => new TestAsyncParser(services)
47-
}
48-
}).grammar;
45+
const services = getServices();
4946
// This file should take a few seconds to parse
5047
const file = createLargeFile(100000);
5148
const asyncParser = services.parser.AsyncParser;
@@ -63,11 +60,72 @@ describe('WorkerThreadAsyncParser', () => {
6360
expect(end - start).toBeLessThan(1000);
6461
});
6562

63+
test('async parsing can be cancelled and then restarted', async () => {
64+
const services = getServices();
65+
// This file should take a few seconds to parse
66+
const file = createLargeFile(100000);
67+
const asyncParser = services.parser.AsyncParser;
68+
const cancellationTokenSource = new CancellationTokenSource();
69+
setTimeout(() => cancellationTokenSource.cancel(), 50);
70+
try {
71+
await asyncParser.parse<Grammar>(file, cancellationTokenSource.token);
72+
fail('Parsing should have been cancelled');
73+
} catch (err) {
74+
expect(isOperationCancelled(err)).toBe(true);
75+
}
76+
// Calling this method should recreate the worker and parse the file correctly
77+
const result = await asyncParser.parse<Grammar>(createLargeFile(10), CancellationToken.None);
78+
expect(result.value.name).toBe('Test');
79+
});
80+
81+
test('async parsing yields correct CST', async () => {
82+
const services = getServices();
83+
const file = createLargeFile(10);
84+
const result = await services.parser.AsyncParser.parse<Grammar>(file, CancellationToken.None);
85+
const index = file.indexOf('TestRule');
86+
// Assert that the CST can be found at all from the root node
87+
// This indicates that the CST is correctly linked to itself
88+
const node = CstUtils.findLeafNodeAtOffset(result.value.$cstNode!, index)!;
89+
expect(node).toBeDefined();
90+
expect(node.text).toBe('TestRule0');
91+
// Assert that the CST node is correctly linked to its container elements
92+
expect(node.container?.container).toBeDefined();
93+
expect(node.container!.container!.text).toBe('TestRule0: name="Hello";');
94+
// Assert that the CST node has a reference to the root
95+
expect(node.root).toBeDefined();
96+
expect(node.root.fullText).toBe(file);
97+
// Assert that the CST node has a reference to the correct AST node
98+
const astNode = node?.astNode as GrammarAST.ParserRule;
99+
expect(astNode).toBeDefined();
100+
expect(astNode.$type).toBe(GrammarAST.ParserRule);
101+
expect(astNode.name).toBe('TestRule0');
102+
});
103+
104+
test('parser errors are correctly transmitted', async () => {
105+
const services = getServices();
106+
const file = 'grammar Test Rule: name="Hello" // missing semicolon';
107+
const result = await services.parser.AsyncParser.parse<Grammar>(file, CancellationToken.None);
108+
expect(result.parserErrors).toHaveLength(1);
109+
expect(result.parserErrors[0].name).toBe('MismatchedTokenException');
110+
expect(result.parserErrors[0]).toHaveProperty('previousToken');
111+
});
112+
66113
function createLargeFile(size: number): string {
67-
let result = 'grammar Test;\n';
114+
let result = 'grammar Test\n';
68115
for (let i = 0; i < size; i++) {
69116
result += 'TestRule' + i + ': name="Hello";\n';
70117
}
71118
return result;
72119
}
120+
121+
function getServices(): LangiumServices {
122+
const services = createLangiumGrammarServices(EmptyFileSystem, undefined, {
123+
parser: {
124+
AsyncParser: (services) => new TestAsyncParser(services)
125+
}
126+
}).grammar;
127+
// We usually only need one thread for testing
128+
(services.parser.AsyncParser as TestAsyncParser).setThreadCount(1);
129+
return services;
130+
}
73131
});

packages/langium/test/parser/worker-thread.js

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,6 @@ const hydrator = services.serializer.Hydrator;
1414

1515
parentPort.on('message', text => {
1616
const result = parser.parse(text);
17-
const dehydrated = hydrator.dehydrate(result.value);
18-
parentPort.postMessage({
19-
...result,
20-
value: dehydrated
21-
});
17+
const dehydrated = hydrator.dehydrate(result);
18+
parentPort.postMessage(dehydrated);
2219
});

0 commit comments

Comments
 (0)