Skip to content

Commit e004619

Browse files
Validation for multiple assignments takes rewrite actions into account now (#1766)
* recursively take created objects in groups/alternatives into account, added some more specific test cases * simplified and reworked the validation to support actions as top-level elements in parser rules as well (was another bug) * derived test cases from the discussion in #1756 * refactoring: inlined method * improved error logging
1 parent 88f176c commit e004619

File tree

3 files changed

+190
-85
lines changed

3 files changed

+190
-85
lines changed

packages/langium/src/grammar/validation/validator.ts

Lines changed: 70 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -877,18 +877,13 @@ export class LangiumGrammarValidator {
877877
checkOperatorMultiplicitiesForMultiAssignments(rule: ast.ParserRule, accept: ValidationAcceptor): void {
878878
// for usual parser rules AND for fragments, but not for data type rules!
879879
if (!rule.dataType) {
880-
this.checkOperatorMultiplicitiesForMultiAssignmentsLogic([rule.definition], accept);
880+
this.checkOperatorMultiplicitiesForMultiAssignmentsIndependent([rule.definition], accept);
881881
}
882882
}
883883

884-
private checkOperatorMultiplicitiesForMultiAssignmentsLogic(startNodes: AstNode[], accept: ValidationAcceptor): void {
885-
// new map to store usage information of the assignments
886-
const map: Map<string, AssignmentUse> = new Map();
887-
888-
// top-down traversal for all starting nodes
889-
for (const node of startNodes) {
890-
this.checkAssignmentNumbersForNode(node, 1, map, accept);
891-
}
884+
private checkOperatorMultiplicitiesForMultiAssignmentsIndependent(startNodes: AstNode[], accept: ValidationAcceptor, map: Map<string, AssignmentUse> = new Map()): void {
885+
// check all starting nodes
886+
this.checkOperatorMultiplicitiesForMultiAssignmentsNested(startNodes, 1, map, accept);
892887

893888
// create the warnings
894889
for (const entry of map.values()) {
@@ -906,72 +901,79 @@ export class LangiumGrammarValidator {
906901
}
907902
}
908903

909-
private checkAssignmentNumbersForNode(currentNode: AstNode, parentMultiplicity: number, map: Map<string, AssignmentUse>, accept: ValidationAcceptor) {
910-
// the current element can occur multiple times => its assignments can occur multiple times as well
911-
let currentMultiplicity = parentMultiplicity;
912-
if (ast.isAbstractElement(currentNode) && isArrayCardinality(currentNode.cardinality)) {
913-
currentMultiplicity *= 2; // note, that the result is not exact (but it is sufficient for the current case)!
914-
}
904+
private checkOperatorMultiplicitiesForMultiAssignmentsNested(nodes: AstNode[], parentMultiplicity: number, map: Map<string, AssignmentUse>, accept: ValidationAcceptor): boolean {
905+
let resultCreatedNewObject = false;
906+
// check all given elements
907+
for (let i = 0; i < nodes.length; i++) {
908+
const currentNode = nodes[i];
909+
910+
// Tree-Rewrite-Actions are a special case: a new object is created => following assignments are put into the new object
911+
if (ast.isAction(currentNode) && currentNode.feature) {
912+
// (This does NOT count for unassigned actions, i.e. actions without feature name, since they change only the type of the current object.)
913+
const mapForNewObject = new Map();
914+
storeAssignmentUse(mapForNewObject, currentNode.feature, 1, currentNode); // remember the special rewriting feature
915+
// all following nodes are put into the new object => check their assignments independently
916+
this.checkOperatorMultiplicitiesForMultiAssignmentsIndependent(nodes.slice(i + 1), accept, mapForNewObject);
917+
resultCreatedNewObject = true;
918+
break; // breaks the current loop
919+
}
915920

916-
// assignment
917-
if (ast.isAssignment(currentNode)) {
918-
storeAssignmentUse(map, currentNode.feature, 1 * currentMultiplicity, currentNode);
919-
}
921+
// all other elements don't create new objects themselves:
920922

921-
// Search for assignments in used fragments as well, since their property values are stored in the current object.
922-
// But do not search in calls of regular parser rules, since parser rules create new objects.
923-
if (ast.isRuleCall(currentNode) && ast.isParserRule(currentNode.rule.ref) && currentNode.rule.ref.fragment) {
924-
this.checkAssignmentNumbersForNode(currentNode.rule.ref.definition, currentMultiplicity, map, accept);
925-
}
923+
// the current element can occur multiple times => its assignments can occur multiple times as well
924+
let currentMultiplicity = parentMultiplicity;
925+
if (ast.isAbstractElement(currentNode) && isArrayCardinality(currentNode.cardinality)) {
926+
currentMultiplicity *= 2; // note that the result is not exact (but it is sufficient for the current use case)!
927+
}
926928

927-
// rewriting actions are a special case for assignments
928-
if (ast.isAction(currentNode) && currentNode.feature) {
929-
storeAssignmentUse(map, currentNode.feature, 1 * currentMultiplicity, currentNode);
930-
}
929+
// assignment
930+
if (ast.isAssignment(currentNode)) {
931+
storeAssignmentUse(map, currentNode.feature, currentMultiplicity, currentNode);
932+
}
931933

932-
// look for assignments to the same feature nested within groups
933-
if (ast.isGroup(currentNode) || ast.isUnorderedGroup(currentNode) || ast.isAlternatives(currentNode)) {
934-
const mapAllAlternatives: Map<string, AssignmentUse> = new Map(); // store assignments for Alternatives separately
935-
let nodesForNewObject: AstNode[] = [];
936-
// check all elements inside the current group
937-
for (const child of currentNode.elements) {
938-
if (ast.isAction(child)) {
939-
// Actions are a special case: a new object is created => following assignments are put into the new object
940-
// (This counts for rewriting actions as well as for unassigned actions, i.e. actions without feature name)
941-
if (nodesForNewObject.length > 0) {
942-
// all collected nodes are put into the new object => check their assignments independently
943-
this.checkOperatorMultiplicitiesForMultiAssignmentsLogic(nodesForNewObject, accept);
944-
// is it possible to have two or more Actions within the same parser rule? the grammar allows that ...
945-
nodesForNewObject = [];
946-
}
947-
// push the current node into a new object
948-
nodesForNewObject.push(child);
949-
} else {
950-
// for non-Actions
951-
if (nodesForNewObject.length > 0) {
952-
// nodes go into a new object
953-
nodesForNewObject.push(child);
954-
} else {
955-
// count the relevant child assignments
956-
if (ast.isAlternatives(currentNode)) {
957-
// for alternatives, only a single alternative is used => assume the worst case and take the maximum number of assignments
958-
const mapCurrentAlternative: Map<string, AssignmentUse> = new Map();
959-
this.checkAssignmentNumbersForNode(child, currentMultiplicity, mapCurrentAlternative, accept);
960-
mergeAssignmentUse(mapCurrentAlternative, mapAllAlternatives, (s, t) => Math.max(s, t));
961-
} else {
962-
// all members of the group are relavant => collect them all
963-
this.checkAssignmentNumbersForNode(child, currentMultiplicity, map, accept);
964-
}
934+
// Search for assignments in used fragments as well, since their property values are stored in the current object.
935+
// But do not search in calls of regular parser rules, since parser rules create new objects.
936+
if (ast.isRuleCall(currentNode) && ast.isParserRule(currentNode.rule.ref) && currentNode.rule.ref.fragment) {
937+
const createdNewObject = this.checkOperatorMultiplicitiesForMultiAssignmentsNested([currentNode.rule.ref.definition], currentMultiplicity, map, accept);
938+
resultCreatedNewObject = createdNewObject || resultCreatedNewObject;
939+
}
940+
941+
// look for assignments to the same feature nested within groups
942+
if (ast.isGroup(currentNode) || ast.isUnorderedGroup(currentNode)) {
943+
// all members of the group are relavant => collect them all
944+
const mapGroup: Map<string, AssignmentUse> = new Map(); // store assignments for Alternatives separately
945+
const createdNewObject = this.checkOperatorMultiplicitiesForMultiAssignmentsNested(currentNode.elements, 1, mapGroup, accept);
946+
mergeAssignmentUse(mapGroup, map, createdNewObject
947+
? (s, t) => (s + t) // if a new object is created in the group: ignore the current multiplicity, since a new object is created for each loop cycle!
948+
: (s, t) => (s * currentMultiplicity + t) // otherwise as usual: take the current multiplicity into account
949+
);
950+
resultCreatedNewObject = createdNewObject || resultCreatedNewObject;
951+
}
952+
953+
// for alternatives, only a single alternative is used => assume the worst case and take the maximum number of assignments
954+
if (ast.isAlternatives(currentNode)) {
955+
const mapAllAlternatives: Map<string, AssignmentUse> = new Map(); // store assignments for Alternatives separately
956+
let countCreatedObjects = 0;
957+
for (const child of currentNode.elements) {
958+
const mapCurrentAlternative: Map<string, AssignmentUse> = new Map();
959+
const createdNewObject = this.checkOperatorMultiplicitiesForMultiAssignmentsNested([child], 1, mapCurrentAlternative, accept);
960+
mergeAssignmentUse(mapCurrentAlternative, mapAllAlternatives, createdNewObject
961+
? (s, t) => Math.max(s, t) // if a new object is created in an alternative: ignore the current multiplicity, since a new object is created for each loop cycle!
962+
: (s, t) => Math.max(s * currentMultiplicity, t) // otherwise as usual: take the current multiplicity into account
963+
);
964+
if (createdNewObject) {
965+
countCreatedObjects++;
965966
}
966967
}
967-
}
968-
// merge alternatives
969-
mergeAssignmentUse(mapAllAlternatives, map);
970-
if (nodesForNewObject.length >= 1) {
971-
// these nodes are put into a new object => check their assignments independently
972-
this.checkOperatorMultiplicitiesForMultiAssignmentsLogic(nodesForNewObject, accept);
968+
// merge alternatives
969+
mergeAssignmentUse(mapAllAlternatives, map);
970+
// since we assume the worst case, we define, that the entire Alternatives node created a new object, if ALL its alternatives created a new object
971+
if (countCreatedObjects === currentNode.elements.length) {
972+
resultCreatedNewObject = true;
973+
}
973974
}
974975
}
976+
return resultCreatedNewObject; // indicates, whether a new object was created
975977
}
976978

977979
checkInterfacePropertyTypes(interfaceDecl: ast.Interface, accept: ValidationAcceptor): void {
@@ -1243,6 +1245,7 @@ function mergeAssignmentUse(mapSoure: Map<string, AssignmentUse>, mapTarget: Map
12431245
target.counter = counterOperation(source.counter, target.counter);
12441246
} else {
12451247
mapTarget.set(key, source);
1248+
source.counter = counterOperation(source.counter, 0);
12461249
}
12471250
}
12481251
mapSoure.clear();

packages/langium/src/test/langium-test.ts

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,24 @@
44
* terms of the MIT License, which is available in the project root.
55
******************************************************************************/
66

7+
import * as assert from 'node:assert';
78
import type { CompletionItem, CompletionList, Diagnostic, DocumentSymbol, FoldingRange, FormattingOptions, Range, ReferenceParams, SemanticTokensParams, SemanticTokenTypes, TextDocumentIdentifier, TextDocumentPositionParams, WorkspaceSymbol } from 'vscode-languageserver-protocol';
9+
import { DiagnosticSeverity, MarkupContent } from 'vscode-languageserver-types';
10+
import { normalizeEOL } from '../generate/template-string.js';
11+
import type { LangiumServices, LangiumSharedLSPServices } from '../lsp/lsp-services.js';
12+
import { SemanticTokensDecoder } from '../lsp/semantic-token-provider.js';
13+
import type { ParserOptions } from '../parser/langium-parser.js';
814
import type { LangiumCoreServices, LangiumSharedCoreServices } from '../services.js';
915
import type { AstNode, CstNode, Properties } from '../syntax-tree.js';
10-
import { type LangiumDocument, TextDocument } from '../workspace/documents.js';
11-
import type { BuildOptions } from '../workspace/document-builder.js';
1216
import type { AsyncDisposable } from '../utils/disposable.js';
13-
import type { LangiumServices, LangiumSharedLSPServices } from '../lsp/lsp-services.js';
14-
import type { ParserOptions } from '../parser/langium-parser.js';
15-
import { DiagnosticSeverity, MarkupContent } from 'vscode-languageserver-types';
16-
import { escapeRegExp } from '../utils/regexp-utils.js';
17-
import { URI } from '../utils/uri-utils.js';
17+
import { Disposable } from '../utils/disposable.js';
1818
import { findNodeForProperty } from '../utils/grammar-utils.js';
19-
import { SemanticTokensDecoder } from '../lsp/semantic-token-provider.js';
20-
import * as assert from 'node:assert';
19+
import { escapeRegExp } from '../utils/regexp-utils.js';
2120
import { stream } from '../utils/stream.js';
22-
import { Disposable } from '../utils/disposable.js';
23-
import { normalizeEOL } from '../generate/template-string.js';
21+
import { URI } from '../utils/uri-utils.js';
2422
import { DocumentValidator } from '../validation/document-validator.js';
23+
import type { BuildOptions } from '../workspace/document-builder.js';
24+
import { TextDocument, type LangiumDocument } from '../workspace/documents.js';
2525

2626
export interface ParseHelperOptions extends BuildOptions {
2727
/**
@@ -682,7 +682,7 @@ export function filterByOptions<T extends AstNode = AstNode, N extends AstNode =
682682

683683
export function expectNoIssues<T extends AstNode = AstNode, N extends AstNode = AstNode>(validationResult: ValidationResult<T>, filterOptions?: ExpectDiagnosticOptions<N>): void {
684684
const filtered = filterOptions ? filterByOptions<T, N>(validationResult, filterOptions) : validationResult.diagnostics;
685-
expectedFunction(filtered.length, 0, `Expected no issues, but found ${filtered.length}`);
685+
expectedFunction(filtered.length, 0, `Expected no issues, but found ${filtered.length}:\n${printDiagnostics(filtered)}`);
686686
}
687687

688688
export function expectIssue<T extends AstNode = AstNode, N extends AstNode = AstNode>(validationResult: ValidationResult<T>, filterOptions?: ExpectDiagnosticOptions<N>): void {
@@ -711,6 +711,10 @@ export function expectWarning<T extends AstNode = AstNode, N extends AstNode = A
711711
});
712712
}
713713

714+
export function printDiagnostics(diagnostics: Diagnostic[] | undefined): string {
715+
return diagnostics?.map(d => `line ${d.range.start.line}, column ${d.range.start.character}: ${d.message}`).join('\n') ?? '';
716+
}
717+
714718
/**
715719
* Add the given document to the `TextDocuments` service, simulating it being opened in an editor.
716720
*

0 commit comments

Comments
 (0)