Skip to content

Commit 91219ec

Browse files
committed
Detect cyclic reference resolution
1 parent 6c602d5 commit 91219ec

File tree

2 files changed

+103
-10
lines changed

2 files changed

+103
-10
lines changed

packages/langium/src/references/linker.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type { LangiumDocument, LangiumDocuments } from '../workspace/documents.j
1111
import type { ScopeProvider } from './scope-provider.js';
1212
import { CancellationToken } from '../utils/cancellation.js';
1313
import { isAstNode, isAstNodeDescription, isLinkingError } from '../syntax-tree.js';
14-
import { getDocument, streamAst, streamReferences } from '../utils/ast-utils.js';
14+
import { findRootNode, streamAst, streamReferences } from '../utils/ast-utils.js';
1515
import { interruptAndCheck } from '../utils/promise-utils.js';
1616
import { DocumentState } from '../workspace/documents.js';
1717

@@ -67,8 +67,10 @@ export interface Linker {
6767

6868
}
6969

70+
const ref_resolving = Symbol('ref_resolving');
71+
7072
interface DefaultReference extends Reference {
71-
_ref?: AstNode | LinkingError;
73+
_ref?: AstNode | LinkingError | typeof ref_resolving;
7274
_nodeDescription?: AstNodeDescription;
7375
}
7476

@@ -96,6 +98,7 @@ export class DefaultLinker implements Linker {
9698
const ref = refInfo.reference as DefaultReference;
9799
// The reference may already have been resolved lazily by accessing its `ref` property.
98100
if (ref._ref === undefined) {
101+
ref._ref = ref_resolving;
99102
try {
100103
const description = this.getCandidate(refInfo);
101104
if (isLinkingError(description)) {
@@ -106,12 +109,17 @@ export class DefaultLinker implements Linker {
106109
// The target document is already loaded
107110
const linkedNode = this.loadAstNode(description);
108111
ref._ref = linkedNode ?? this.createLinkingError(refInfo, description);
112+
} else {
113+
// Try to load the target AST node later using the already provided description
114+
ref._ref = undefined;
109115
}
110116
}
111117
} catch (err) {
118+
console.error(`An error occurred while resolving reference to '${ref.$refText}':`, err);
119+
const errorMessage = (err as Error).message ?? String(err);
112120
ref._ref = {
113121
...refInfo,
114-
message: `An error occurred while resolving reference to '${ref.$refText}': ${err}`
122+
message: `An error occurred while resolving reference to '${ref.$refText}': ${errorMessage}`
115123
};
116124
}
117125
// Add the reference to the document's array of references
@@ -155,15 +163,18 @@ export class DefaultLinker implements Linker {
155163
linker.createLinkingError({ reference, container: node, property }, this._nodeDescription);
156164
} else if (this._ref === undefined) {
157165
// The reference has not been linked yet, so do that now.
166+
this._ref = ref_resolving;
167+
const document = findRootNode(node).$document;
158168
const refData = linker.getLinkedNode({ reference, container: node, property });
159-
if (refData.error && getDocument(node).state < DocumentState.ComputedScopes) {
169+
if (refData.error && document && document.state < DocumentState.ComputedScopes) {
160170
// Document scope is not ready, don't set `this._ref` so linker can retry later.
161-
return undefined;
171+
return this._ref = undefined;
162172
}
163173
this._ref = refData.node ?? refData.error;
164174
this._nodeDescription = refData.descr;
165-
const document = getDocument(node);
166-
document.references.push(this);
175+
document?.references.push(this);
176+
} else if (this._ref === ref_resolving) {
177+
throw new Error(`Cyclic reference resolution detected: ${linker.astNodeLocator.getAstNodePath(node)}/${property} (symbol '${refText}')`);
167178
}
168179
return isAstNode(this._ref) ? this._ref : undefined;
169180
},
@@ -195,10 +206,12 @@ export class DefaultLinker implements Linker {
195206
};
196207
}
197208
} catch (err) {
209+
console.error(`An error occurred while resolving reference to '${refInfo.reference.$refText}':`, err);
210+
const errorMessage = (err as Error).message ?? String(err);
198211
return {
199212
error: {
200213
...refInfo,
201-
message: `An error occurred while resolving reference to '${refInfo.reference.$refText}': ${err}`
214+
message: `An error occurred while resolving reference to '${refInfo.reference.$refText}': ${errorMessage}`
202215
}
203216
};
204217
}
@@ -218,8 +231,8 @@ export class DefaultLinker implements Linker {
218231
protected createLinkingError(refInfo: ReferenceInfo, targetDescription?: AstNodeDescription): LinkingError {
219232
// Check whether the document is sufficiently processed by the DocumentBuilder. If not, this is a hint for a bug
220233
// in the language implementation.
221-
const document = getDocument(refInfo.container);
222-
if (document.state < DocumentState.ComputedScopes) {
234+
const document = findRootNode(refInfo.container).$document;
235+
if (document && document.state < DocumentState.ComputedScopes) {
223236
console.warn(`Attempted reference resolution before document reached ComputedScopes state (${document.uri}).`);
224237
}
225238
const referenceType = this.reflection.getReferenceType(refInfo);
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/******************************************************************************
2+
* Copyright 2024 TypeFox GmbH
3+
* This program and the accompanying materials are made available under the
4+
* terms of the MIT License, which is available in the project root.
5+
******************************************************************************/
6+
7+
import { DefaultScopeProvider, type AstNode, type LangiumCoreServices, type Module, type PartialLangiumCoreServices, type Reference, type ReferenceInfo, type Scope } from 'langium';
8+
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
9+
import { createServicesForGrammar } from 'langium/grammar';
10+
import { clearDocuments, parseHelper } from 'langium/test';
11+
12+
describe('DefaultLinker', async () => {
13+
const grammar = `
14+
grammar Test
15+
entry Root:
16+
nodes+=Node* referrers+=Referrer*;
17+
Node:
18+
'node' name=ID;
19+
Referrer:
20+
'referrer' node=[Node];
21+
hidden terminal WS: /\\s+/;
22+
terminal ID: /[_a-zA-Z][\\w_]*/;
23+
`;
24+
const cyclicModule: Module<LangiumCoreServices, PartialLangiumCoreServices> = {
25+
references: {
26+
ScopeProvider: (services) => new BrokenScopeProvider(services)
27+
}
28+
};
29+
const cyclicServices = await createServicesForGrammar({
30+
grammar,
31+
module: cyclicModule
32+
});
33+
const cyclicParser = parseHelper<Root>(cyclicServices);
34+
35+
let errorLog: typeof console.error;
36+
beforeEach(() => {
37+
clearDocuments(cyclicServices);
38+
errorLog = console.error;
39+
console.error = () => {};
40+
});
41+
afterEach(() => {
42+
console.error = errorLog;
43+
});
44+
45+
test('throws an error upon cyclic resolution', async () => {
46+
const document = await cyclicParser(`
47+
node a
48+
referrer a
49+
`, { documentUri: 'test://test.model' });
50+
const model = document.parseResult.value;
51+
expect(model.referrers[0]?.node?.error).toBeDefined();
52+
expect(model.referrers[0].node.error?.message).toBe(
53+
"An error occurred while resolving reference to 'a': Cyclic reference resolution detected: /referrers@0/node (symbol 'a')");
54+
});
55+
56+
});
57+
58+
interface Root extends AstNode {
59+
nodes: Node[]
60+
referrers: Referrer[]
61+
}
62+
63+
interface Node extends AstNode {
64+
name: string
65+
}
66+
67+
interface Referrer extends AstNode {
68+
node: Reference<Node>
69+
}
70+
71+
class BrokenScopeProvider extends DefaultScopeProvider {
72+
override getScope(context: ReferenceInfo): Scope {
73+
if (context.container.$type === 'Referrer' && context.property === 'node') {
74+
const referrer = context.container as Referrer;
75+
// FORBIDDEN: access the reference that we're trying to find a scope for
76+
referrer.node.ref;
77+
}
78+
return super.getScope(context);
79+
}
80+
}

0 commit comments

Comments
 (0)