Skip to content

Commit 60f8b6e

Browse files
Validate mixed use of ?=, = and += for the same feature
1 parent 2b2bf37 commit 60f8b6e

File tree

2 files changed

+92
-15
lines changed

2 files changed

+92
-15
lines changed

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

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export function registerValidationChecks(services: LangiumGrammarServices): void
5858
validator.checkRuleParameters,
5959
validator.checkEmptyParserRule,
6060
validator.checkParserRuleReservedName,
61-
validator.checkOperatorMultiplicitiesForMultiAssignments,
61+
validator.checkAssignmentsToTheSameFeature,
6262
],
6363
InfixRule: [
6464
validator.checkInfixRuleDataType,
@@ -139,6 +139,7 @@ export namespace IssueCodes {
139139
export const OptionalUnorderedGroup = 'optional-unordered-group';
140140
export const ParsingRuleEmpty = 'parsing-rule-empty';
141141
export const ReplaceOperatorMultiAssignment = 'replace-operator-for-multi-assignments';
142+
export const MixedAssignmentOperators = 'mixed-use-of-assignment-operators';
142143
}
143144

144145
export class LangiumGrammarValidator {
@@ -1018,22 +1019,45 @@ export class LangiumGrammarValidator {
10181019
}
10191020
}
10201021

1021-
/** This validation recursively looks at all assignments (and rewriting actions) with '=' as assignment operator and checks,
1022-
* whether the operator should be '+=' instead. */
1023-
checkOperatorMultiplicitiesForMultiAssignments(rule: ast.ParserRule, accept: ValidationAcceptor): void {
1022+
/**
1023+
* This validation recursively collects all assignments (and rewriting actions) to the same feature and validates them:
1024+
* Assignment operators '?=', '=' and '+=' should not be mixed.
1025+
* Assignments with '=' as assignment operator should be replaced be '+=', if assignments to the feature might occure more than once.
1026+
*/
1027+
checkAssignmentsToTheSameFeature(rule: ast.ParserRule, accept: ValidationAcceptor): void {
10241028
// for usual parser rules AND for fragments, but not for data type rules!
10251029
if (!rule.dataType) {
1026-
this.checkOperatorMultiplicitiesForMultiAssignmentsIndependent([rule.definition], accept);
1030+
this.checkAssignmentsToTheSameFeatureIndependent([rule.definition], accept);
10271031
}
10281032
}
10291033

1030-
private checkOperatorMultiplicitiesForMultiAssignmentsIndependent(startNodes: AstNode[], accept: ValidationAcceptor, map: Map<string, AssignmentUse> = new Map()): void {
1034+
private checkAssignmentsToTheSameFeatureIndependent(startNodes: AstNode[], accept: ValidationAcceptor, map: Map<string, AssignmentUse> = new Map()): void {
10311035
// check all starting nodes
1032-
this.checkOperatorMultiplicitiesForMultiAssignmentsNested(startNodes, 1, map, accept);
1036+
this.checkAssignmentsToTheSameFeatureNested(startNodes, 1, map, accept);
10331037

10341038
// create the warnings
10351039
for (const entry of map.values()) {
1036-
if (entry.counter >= 2) {
1040+
// check mixed use of ?=, = and +=
1041+
const usedOperators = new Set<string>();
1042+
for (const assignment of entry.assignments) {
1043+
if (assignment.operator) {
1044+
usedOperators.add(assignment.operator);
1045+
}
1046+
}
1047+
if (usedOperators.size >= 2) {
1048+
for (const assignment of entry.assignments) {
1049+
accept(
1050+
'warning',
1051+
`Don't mix operators (${stream(usedOperators).map(op => `'${op}'`).join(', ')}) when assigning values to the same feature '${assignment.feature}'.`,
1052+
{
1053+
node: assignment,
1054+
property: 'feature', // use 'feature' instead of 'operator', since it is pretty hard to see
1055+
data: diagnosticData(IssueCodes.MixedAssignmentOperators), // no code action, but is relevant for serializability
1056+
}
1057+
);
1058+
}
1059+
} else if (entry.counter >= 2) {
1060+
// check for multiple assignments with '?=' or '=' instead of '+='
10371061
for (const assignment of entry.assignments) {
10381062
if (assignment.operator !== '+=') {
10391063
accept(
@@ -1042,16 +1066,18 @@ export class LangiumGrammarValidator {
10421066
{
10431067
node: assignment,
10441068
property: 'feature', // use 'feature' instead of 'operator', since it is pretty hard to see
1045-
data: diagnosticData(IssueCodes.ReplaceOperatorMultiAssignment),
1069+
data: diagnosticData(IssueCodes.ReplaceOperatorMultiAssignment), // for code action, is relevant for serializability as well
10461070
}
10471071
);
10481072
}
10491073
}
1074+
} else {
1075+
// the assignments to this feature are fine
10501076
}
10511077
}
10521078
}
10531079

1054-
private checkOperatorMultiplicitiesForMultiAssignmentsNested(nodes: AstNode[], parentMultiplicity: number, map: Map<string, AssignmentUse>, accept: ValidationAcceptor): boolean {
1080+
private checkAssignmentsToTheSameFeatureNested(nodes: AstNode[], parentMultiplicity: number, map: Map<string, AssignmentUse>, accept: ValidationAcceptor): boolean {
10551081
let resultCreatedNewObject = false;
10561082
// check all given elements
10571083
for (let i = 0; i < nodes.length; i++) {
@@ -1063,7 +1089,7 @@ export class LangiumGrammarValidator {
10631089
const mapForNewObject = new Map();
10641090
storeAssignmentUse(mapForNewObject, currentNode.feature, 1, currentNode); // remember the special rewriting feature
10651091
// all following nodes are put into the new object => check their assignments independently
1066-
this.checkOperatorMultiplicitiesForMultiAssignmentsIndependent(nodes.slice(i + 1), accept, mapForNewObject);
1092+
this.checkAssignmentsToTheSameFeatureIndependent(nodes.slice(i + 1), accept, mapForNewObject);
10671093
resultCreatedNewObject = true;
10681094
break; // breaks the current loop
10691095
}
@@ -1084,15 +1110,15 @@ export class LangiumGrammarValidator {
10841110
// Search for assignments in used fragments as well, since their property values are stored in the current object.
10851111
// But do not search in calls of regular parser rules, since parser rules create new objects.
10861112
if (ast.isRuleCall(currentNode) && ast.isParserRule(currentNode.rule.ref) && currentNode.rule.ref.fragment) {
1087-
const createdNewObject = this.checkOperatorMultiplicitiesForMultiAssignmentsNested([currentNode.rule.ref.definition], currentMultiplicity, map, accept);
1113+
const createdNewObject = this.checkAssignmentsToTheSameFeatureNested([currentNode.rule.ref.definition], currentMultiplicity, map, accept);
10881114
resultCreatedNewObject = createdNewObject || resultCreatedNewObject;
10891115
}
10901116

10911117
// look for assignments to the same feature nested within groups
10921118
if (ast.isGroup(currentNode) || ast.isUnorderedGroup(currentNode)) {
10931119
// all members of the group are relavant => collect them all
10941120
const mapGroup: Map<string, AssignmentUse> = new Map(); // store assignments for Alternatives separately
1095-
const createdNewObject = this.checkOperatorMultiplicitiesForMultiAssignmentsNested(currentNode.elements, 1, mapGroup, accept);
1121+
const createdNewObject = this.checkAssignmentsToTheSameFeatureNested(currentNode.elements, 1, mapGroup, accept);
10961122
mergeAssignmentUse(mapGroup, map, createdNewObject
10971123
? (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!
10981124
: (s, t) => (s * currentMultiplicity + t) // otherwise as usual: take the current multiplicity into account
@@ -1106,7 +1132,7 @@ export class LangiumGrammarValidator {
11061132
let countCreatedObjects = 0;
11071133
for (const alternative of currentNode.elements) {
11081134
const mapCurrentAlternative: Map<string, AssignmentUse> = new Map();
1109-
const createdNewObject = this.checkOperatorMultiplicitiesForMultiAssignmentsNested([alternative], 1, mapCurrentAlternative, accept);
1135+
const createdNewObject = this.checkAssignmentsToTheSameFeatureNested([alternative], 1, mapCurrentAlternative, accept);
11101136
mergeAssignmentUse(mapCurrentAlternative, mapAllAlternatives, createdNewObject
11111137
? (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!
11121138
: (s, t) => Math.max(s * currentMultiplicity, t) // otherwise as usual: take the current multiplicity into account

packages/langium/test/grammar/grammar-validator.test.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
******************************************************************************/
66

77
import type { AstNode, Grammar, GrammarAST as GrammarTypes, LangiumDocument, Properties } from 'langium';
8-
import { AstUtils, EmptyFileSystem, GrammarAST, URI } from 'langium';
8+
import { AstUtils, EmptyFileSystem, GrammarAST, stream, URI } from 'langium';
99
import { IssueCodes, createLangiumGrammarServices } from 'langium/grammar';
1010
import type { ValidationResult } from 'langium/test';
1111
import { clearDocuments, expectError, expectIssue, expectNoIssues, expectWarning, parseHelper, validationHelper } from 'langium/test';
@@ -1643,6 +1643,57 @@ describe('Assignments with = (or ?=) instead of +=', () => {
16431643

16441644
});
16451645

1646+
describe('Mixed use of "?=", "=" and "+=" for the same feature', () => {
1647+
function getMessage(featureName: string, ...operators: Array<'?=' | '=' | '+='>): string {
1648+
return `Don't mix operators (${stream(operators).map(op => `'${op}'`).join(', ')}) when assigning values to the same feature '${featureName}'.`;
1649+
}
1650+
function getGrammar(content: string): string {
1651+
return `
1652+
grammar HelloWorld
1653+
${content}
1654+
hidden terminal WS: /\\s+/;
1655+
terminal ID: /[_a-zA-Z][\\w_]*/;
1656+
`;
1657+
}
1658+
1659+
test('= and +=', async () => {
1660+
const validation = await validate(getGrammar(`
1661+
entry Person: 'person' name=ID name+=ID;
1662+
`));
1663+
expect(validation.diagnostics.length).toBe(2);
1664+
expect(validation.diagnostics[0].message).toBe(getMessage('name', '=', '+='));
1665+
expect(validation.diagnostics[1].message).toBe(getMessage('name', '=', '+='));
1666+
});
1667+
1668+
test('= and ?=', async () => {
1669+
const validation = await validate(getGrammar(`
1670+
entry Person: 'person' name=ID name?=ID;
1671+
`));
1672+
expect(validation.diagnostics.length).toBe(2);
1673+
expect(validation.diagnostics[0].message).toBe(getMessage('name', '=', '?='));
1674+
expect(validation.diagnostics[1].message).toBe(getMessage('name', '=', '?='));
1675+
});
1676+
1677+
test('?= and +=', async () => {
1678+
const validation = await validate(getGrammar(`
1679+
entry Person: 'person' name?=ID name+=ID;
1680+
`));
1681+
expect(validation.diagnostics.length).toBe(2);
1682+
expect(validation.diagnostics[0].message).toBe(getMessage('name', '?=', '+='));
1683+
expect(validation.diagnostics[1].message).toBe(getMessage('name', '?=', '+='));
1684+
});
1685+
1686+
test('?=, = and +=', async () => {
1687+
const validation = await validate(getGrammar(`
1688+
entry Person: 'person' name?=ID name=ID name+=ID;
1689+
`));
1690+
expect(validation.diagnostics.length).toBe(3);
1691+
expect(validation.diagnostics[0].message).toBe(getMessage('name', '?=', '=', '+='));
1692+
expect(validation.diagnostics[1].message).toBe(getMessage('name', '?=', '=', '+='));
1693+
expect(validation.diagnostics[2].message).toBe(getMessage('name', '?=', '=', '+='));
1694+
});
1695+
});
1696+
16461697
describe('Missing required properties are not arrays or booleans', () => {
16471698

16481699
test('No missing properties', async () => {

0 commit comments

Comments
 (0)