Skip to content

Commit

Permalink
Detect cyclic reference resolution
Browse files Browse the repository at this point in the history
  • Loading branch information
spoenemann committed Jul 15, 2024
1 parent 6c602d5 commit 91219ec
Show file tree
Hide file tree
Showing 2 changed files with 103 additions and 10 deletions.
33 changes: 23 additions & 10 deletions packages/langium/src/references/linker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { LangiumDocument, LangiumDocuments } from '../workspace/documents.j
import type { ScopeProvider } from './scope-provider.js';
import { CancellationToken } from '../utils/cancellation.js';
import { isAstNode, isAstNodeDescription, isLinkingError } from '../syntax-tree.js';
import { getDocument, streamAst, streamReferences } from '../utils/ast-utils.js';
import { findRootNode, streamAst, streamReferences } from '../utils/ast-utils.js';
import { interruptAndCheck } from '../utils/promise-utils.js';
import { DocumentState } from '../workspace/documents.js';

Expand Down Expand Up @@ -67,8 +67,10 @@ export interface Linker {

}

const ref_resolving = Symbol('ref_resolving');

interface DefaultReference extends Reference {
_ref?: AstNode | LinkingError;
_ref?: AstNode | LinkingError | typeof ref_resolving;
_nodeDescription?: AstNodeDescription;
}

Expand Down Expand Up @@ -96,6 +98,7 @@ export class DefaultLinker implements Linker {
const ref = refInfo.reference as DefaultReference;
// The reference may already have been resolved lazily by accessing its `ref` property.
if (ref._ref === undefined) {
ref._ref = ref_resolving;
try {
const description = this.getCandidate(refInfo);
if (isLinkingError(description)) {
Expand All @@ -106,12 +109,17 @@ export class DefaultLinker implements Linker {
// The target document is already loaded
const linkedNode = this.loadAstNode(description);
ref._ref = linkedNode ?? this.createLinkingError(refInfo, description);
} else {
// Try to load the target AST node later using the already provided description
ref._ref = undefined;
}
}
} catch (err) {
console.error(`An error occurred while resolving reference to '${ref.$refText}':`, err);
const errorMessage = (err as Error).message ?? String(err);
ref._ref = {
...refInfo,
message: `An error occurred while resolving reference to '${ref.$refText}': ${err}`
message: `An error occurred while resolving reference to '${ref.$refText}': ${errorMessage}`
};
}
// Add the reference to the document's array of references
Expand Down Expand Up @@ -155,15 +163,18 @@ export class DefaultLinker implements Linker {
linker.createLinkingError({ reference, container: node, property }, this._nodeDescription);
} else if (this._ref === undefined) {
// The reference has not been linked yet, so do that now.
this._ref = ref_resolving;
const document = findRootNode(node).$document;
const refData = linker.getLinkedNode({ reference, container: node, property });
if (refData.error && getDocument(node).state < DocumentState.ComputedScopes) {
if (refData.error && document && document.state < DocumentState.ComputedScopes) {
// Document scope is not ready, don't set `this._ref` so linker can retry later.
return undefined;
return this._ref = undefined;
}
this._ref = refData.node ?? refData.error;
this._nodeDescription = refData.descr;
const document = getDocument(node);
document.references.push(this);
document?.references.push(this);
} else if (this._ref === ref_resolving) {
throw new Error(`Cyclic reference resolution detected: ${linker.astNodeLocator.getAstNodePath(node)}/${property} (symbol '${refText}')`);
}
return isAstNode(this._ref) ? this._ref : undefined;
},
Expand Down Expand Up @@ -195,10 +206,12 @@ export class DefaultLinker implements Linker {
};
}
} catch (err) {
console.error(`An error occurred while resolving reference to '${refInfo.reference.$refText}':`, err);
const errorMessage = (err as Error).message ?? String(err);
return {
error: {
...refInfo,
message: `An error occurred while resolving reference to '${refInfo.reference.$refText}': ${err}`
message: `An error occurred while resolving reference to '${refInfo.reference.$refText}': ${errorMessage}`
}
};
}
Expand All @@ -218,8 +231,8 @@ export class DefaultLinker implements Linker {
protected createLinkingError(refInfo: ReferenceInfo, targetDescription?: AstNodeDescription): LinkingError {
// Check whether the document is sufficiently processed by the DocumentBuilder. If not, this is a hint for a bug
// in the language implementation.
const document = getDocument(refInfo.container);
if (document.state < DocumentState.ComputedScopes) {
const document = findRootNode(refInfo.container).$document;
if (document && document.state < DocumentState.ComputedScopes) {
console.warn(`Attempted reference resolution before document reached ComputedScopes state (${document.uri}).`);
}
const referenceType = this.reflection.getReferenceType(refInfo);
Expand Down
80 changes: 80 additions & 0 deletions packages/langium/test/references/linker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/******************************************************************************
* Copyright 2024 TypeFox GmbH
* This program and the accompanying materials are made available under the
* terms of the MIT License, which is available in the project root.
******************************************************************************/

import { DefaultScopeProvider, type AstNode, type LangiumCoreServices, type Module, type PartialLangiumCoreServices, type Reference, type ReferenceInfo, type Scope } from 'langium';
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { createServicesForGrammar } from 'langium/grammar';
import { clearDocuments, parseHelper } from 'langium/test';

describe('DefaultLinker', async () => {
const grammar = `
grammar Test
entry Root:
nodes+=Node* referrers+=Referrer*;
Node:
'node' name=ID;
Referrer:
'referrer' node=[Node];
hidden terminal WS: /\\s+/;
terminal ID: /[_a-zA-Z][\\w_]*/;
`;
const cyclicModule: Module<LangiumCoreServices, PartialLangiumCoreServices> = {
references: {
ScopeProvider: (services) => new BrokenScopeProvider(services)
}
};
const cyclicServices = await createServicesForGrammar({
grammar,
module: cyclicModule
});
const cyclicParser = parseHelper<Root>(cyclicServices);

let errorLog: typeof console.error;
beforeEach(() => {
clearDocuments(cyclicServices);
errorLog = console.error;
console.error = () => {};
});
afterEach(() => {
console.error = errorLog;
});

test('throws an error upon cyclic resolution', async () => {
const document = await cyclicParser(`
node a
referrer a
`, { documentUri: 'test://test.model' });
const model = document.parseResult.value;
expect(model.referrers[0]?.node?.error).toBeDefined();
expect(model.referrers[0].node.error?.message).toBe(
"An error occurred while resolving reference to 'a': Cyclic reference resolution detected: /referrers@0/node (symbol 'a')");
});

});

interface Root extends AstNode {
nodes: Node[]
referrers: Referrer[]
}

interface Node extends AstNode {
name: string
}

interface Referrer extends AstNode {
node: Reference<Node>
}

class BrokenScopeProvider extends DefaultScopeProvider {
override getScope(context: ReferenceInfo): Scope {
if (context.container.$type === 'Referrer' && context.property === 'node') {
const referrer = context.container as Referrer;
// FORBIDDEN: access the reference that we're trying to find a scope for
referrer.node.ref;
}
return super.getScope(context);
}
}

0 comments on commit 91219ec

Please sign in to comment.