Skip to content

Commit 58e28b7

Browse files
Validation for assignments with = instead of += (#1412)
1 parent 4bb3e78 commit 58e28b7

File tree

2 files changed

+415
-11
lines changed

2 files changed

+415
-11
lines changed

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

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

7+
import type { Range } from 'vscode-languageserver-types';
8+
import { DiagnosticTag } from 'vscode-languageserver-types';
9+
import * as ast from '../../languages/generated/ast.js';
710
import type { NamedAstNode } from '../../references/name-provider.js';
811
import type { References } from '../../references/references.js';
912
import type { AstNode, Properties, Reference } from '../../syntax-tree.js';
10-
import type { Stream } from '../../utils/stream.js';
11-
import type { DiagnosticData, ValidationAcceptor, ValidationChecks } from '../../validation/validation-registry.js';
12-
import type { LangiumDocuments } from '../../workspace/documents.js';
13-
import type { LangiumGrammarServices } from '../langium-grammar-module.js';
14-
import type { Range } from 'vscode-languageserver-types';
15-
import { DiagnosticTag } from 'vscode-languageserver-types';
1613
import { getContainerOfType, streamAllContents } from '../../utils/ast-utils.js';
1714
import { MultiMap } from '../../utils/collections.js';
1815
import { toDocumentSegment } from '../../utils/cst-utils.js';
19-
import { findNameAssignment, findNodeForKeyword, findNodeForProperty, getAllReachableRules, isDataTypeRule, isOptionalCardinality, terminalRegex } from '../../utils/grammar-utils.js';
16+
import { findNameAssignment, findNodeForKeyword, findNodeForProperty, getAllReachableRules, isArrayCardinality, isDataTypeRule, isOptionalCardinality, terminalRegex } from '../../utils/grammar-utils.js';
17+
import type { Stream } from '../../utils/stream.js';
2018
import { stream } from '../../utils/stream.js';
19+
import type { DiagnosticData, ValidationAcceptor, ValidationChecks } from '../../validation/validation-registry.js';
2120
import { diagnosticData } from '../../validation/validation-registry.js';
22-
import * as ast from '../../languages/generated/ast.js';
21+
import type { AstNodeLocator } from '../../workspace/ast-node-locator.js';
22+
import type { LangiumDocuments } from '../../workspace/documents.js';
2323
import { getTypeNameWithoutError, hasDataTypeReturn, isPrimitiveGrammarType, isStringGrammarType, resolveImport, resolveTransitiveImports } from '../internal-grammar-util.js';
24+
import type { LangiumGrammarServices } from '../langium-grammar-module.js';
2425
import { typeDefinitionToPropertyType } from '../type-system/type-collector/declared-types.js';
2526
import { flattenPlainType, isPlainReferenceType } from '../type-system/type-collector/plain-types.js';
2627

@@ -43,6 +44,7 @@ export function registerValidationChecks(services: LangiumGrammarServices): void
4344
validator.checkRuleParametersUsed,
4445
validator.checkEmptyParserRule,
4546
validator.checkParserRuleReservedName,
47+
validator.checkOperatorMultiplicitiesForMultiAssignments,
4648
],
4749
TerminalRule: [
4850
validator.checkTerminalRuleReturnType,
@@ -78,7 +80,7 @@ export function registerValidationChecks(services: LangiumGrammarServices): void
7880
validator.checkUsedHiddenTerminalRule,
7981
validator.checkUsedFragmentTerminalRule,
8082
validator.checkRuleCallParameters,
81-
validator.checkRuleCallMultiplicity
83+
validator.checkMultiRuleCallsAreAssigned
8284
],
8385
TerminalRuleCall: validator.checkUsedHiddenTerminalRule,
8486
CrossReference: [
@@ -118,10 +120,12 @@ export namespace IssueCodes {
118120
export class LangiumGrammarValidator {
119121

120122
protected readonly references: References;
123+
protected readonly nodeLocator: AstNodeLocator;
121124
protected readonly documents: LangiumDocuments;
122125

123126
constructor(services: LangiumGrammarServices) {
124127
this.references = services.references.References;
128+
this.nodeLocator = services.workspace.AstNodeLocator;
125129
this.documents = services.shared.workspace.LangiumDocuments;
126130
}
127131

@@ -722,7 +726,8 @@ export class LangiumGrammarValidator {
722726
}
723727
}
724728

725-
checkRuleCallMultiplicity(call: ast.RuleCall, accept: ValidationAcceptor): void {
729+
/** This validation checks, that parser rules which are called multiple times are assigned (except for fragments). */
730+
checkMultiRuleCallsAreAssigned(call: ast.RuleCall, accept: ValidationAcceptor): void {
726731
const findContainerWithCardinality = (node: AstNode) => {
727732
let result: AstNode | undefined = node;
728733
while (result !== undefined) {
@@ -867,6 +872,108 @@ export class LangiumGrammarValidator {
867872
}
868873
}
869874

875+
/** This validation recursively looks at all assignments (and rewriting actions) with '=' as assignment operator and checks,
876+
* whether the operator should be '+=' instead. */
877+
checkOperatorMultiplicitiesForMultiAssignments(rule: ast.ParserRule, accept: ValidationAcceptor): void {
878+
// for usual parser rules AND for fragments, but not for data type rules!
879+
if (!rule.dataType) {
880+
this.checkOperatorMultiplicitiesForMultiAssignmentsLogic([rule.definition], accept);
881+
}
882+
}
883+
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+
}
892+
893+
// create the warnings
894+
for (const entry of map.values()) {
895+
if (entry.counter >= 2) {
896+
for (const assignment of entry.assignments) {
897+
if (assignment.operator !== '+=') {
898+
accept(
899+
'warning',
900+
`Found multiple assignments to '${assignment.feature}' with the '${assignment.operator}' assignment operator. Consider using '+=' instead to prevent data loss.`,
901+
{ node: assignment, property: 'feature' } // use 'feature' instead of 'operator', since it is pretty hard to see
902+
);
903+
}
904+
}
905+
}
906+
}
907+
}
908+
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+
}
915+
916+
// assignment
917+
if (ast.isAssignment(currentNode)) {
918+
storeAssignmentUse(map, currentNode.feature, 1 * currentMultiplicity, currentNode);
919+
}
920+
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+
}
926+
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+
}
931+
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+
}
965+
}
966+
}
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);
973+
}
974+
}
975+
}
976+
870977
checkInterfacePropertyTypes(interfaceDecl: ast.Interface, accept: ValidationAcceptor): void {
871978
for (const attribute of interfaceDecl.attributes) {
872979
if (attribute.type) {
@@ -1095,3 +1202,48 @@ function findLookAheadGroup(rule: AstNode | undefined): ast.TerminalGroup | unde
10951202
return findLookAheadGroup(terminalGroup.$container);
10961203
}
10971204
}
1205+
1206+
/*
1207+
* Internal helper stuff for collecting information about assignments to features and their cardinalities
1208+
*/
1209+
1210+
interface AssignmentUse {
1211+
/**
1212+
* Collects assignments for the same feature, while an Action represents a "special assignment", when it is a rewrite action.
1213+
* The Set is used in order not to store the same assignment multiple times.
1214+
*/
1215+
assignments: Set<ast.Assignment | ast.Action>;
1216+
/**
1217+
* Note, that this number is not exact and "estimates the potential number",
1218+
* i.e. multiplicities like + and * are counted as 2x/twice,
1219+
* and for alternatives, the worst case is assumed.
1220+
* In other words, here it is enough to know, whether there are two or more assignments possible to the same feature.
1221+
*/
1222+
counter: number;
1223+
}
1224+
1225+
function storeAssignmentUse(map: Map<string, AssignmentUse>, feature: string, increment: number, ...assignments: Array<ast.Assignment | ast.Action>) {
1226+
let entry = map.get(feature);
1227+
if (!entry) {
1228+
entry = {
1229+
assignments: new Set(),
1230+
counter: 0,
1231+
};
1232+
map.set(feature, entry);
1233+
}
1234+
assignments.forEach(a => entry!.assignments.add(a)); // a Set is necessary, since assignments in Fragements might be used multiple times by different parser rules, but they should be marked only once!
1235+
entry.counter += increment;
1236+
}
1237+
1238+
function mergeAssignmentUse(mapSoure: Map<string, AssignmentUse>, mapTarget: Map<string, AssignmentUse>, counterOperation: (s: number, t: number) => number = (s, t) => s + t): void {
1239+
for (const [key, source] of mapSoure.entries()) {
1240+
const target = mapTarget.get(key);
1241+
if (target) {
1242+
source.assignments.forEach(a => target.assignments.add(a));
1243+
target.counter = counterOperation(source.counter, target.counter);
1244+
} else {
1245+
mapTarget.set(key, source);
1246+
}
1247+
}
1248+
mapSoure.clear();
1249+
}

0 commit comments

Comments
 (0)