langium
Version:
A language engineering tool for the Language Server Protocol
1,012 lines • 63 kB
JavaScript
/******************************************************************************
* Copyright 2021 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 { DiagnosticTag } from 'vscode-languageserver-types';
import * as ast from '../../languages/generated/ast.js';
import { getContainerOfType, streamAllContents } from '../../utils/ast-utils.js';
import { MultiMap } from '../../utils/collections.js';
import { toDocumentSegment } from '../../utils/cst-utils.js';
import { assertUnreachable } from '../../utils/errors.js';
import { findNameAssignment, findNodeForKeyword, findNodeForProperty, getAllReachableRules, getAllRulesUsedForCrossReferences, isArrayCardinality, isDataTypeRule, isOptionalCardinality, terminalRegex } from '../../utils/grammar-utils.js';
import { stream } from '../../utils/stream.js';
import { UriUtils } from '../../utils/uri-utils.js';
import { diagnosticData } from '../../validation/validation-registry.js';
import { getTypeNameWithoutError, hasDataTypeReturn, isPrimitiveGrammarType, isStringGrammarType, resolveImport, resolveTransitiveImports } from '../internal-grammar-util.js';
import { typeDefinitionToPropertyType } from '../type-system/type-collector/declared-types.js';
import { flattenPlainType, isPlainReferenceType } from '../type-system/type-collector/plain-types.js';
import { isDeclared, isInferred } from '../workspace/documents.js';
export function registerValidationChecks(services) {
const registry = services.validation.ValidationRegistry;
const validator = services.validation.LangiumGrammarValidator;
const checks = {
Action: [
validator.checkAssignmentReservedName,
],
AbstractRule: validator.checkRuleName,
Assignment: [
validator.checkAssignmentWithFeatureName,
validator.checkAssignmentToFragmentRule,
validator.checkAssignmentTypes,
validator.checkAssignmentReservedName,
validator.checkPredicateNotSupported
],
ParserRule: [
validator.checkParserRuleDataType,
validator.checkRuleParameters,
validator.checkEmptyParserRule,
validator.checkParserRuleReservedName,
validator.checkOperatorMultiplicitiesForMultiAssignments,
],
InfixRule: [
validator.checkInfixRuleDataType,
],
TerminalRule: [
validator.checkTerminalRuleReturnType,
validator.checkHiddenTerminalRule,
validator.checkEmptyTerminalRule
],
InferredType: validator.checkTypeReservedName,
Keyword: [
validator.checkKeyword,
validator.checkPredicateNotSupported
],
UnorderedGroup: validator.checkUnorderedGroup,
Grammar: [
validator.checkGrammarName,
validator.checkEntryGrammarRule,
validator.checkUniqueRuleName,
validator.checkUniqueTypeName,
validator.checkUniqueTypeAndGrammarNames,
validator.checkUniqueImportedRules,
validator.checkDuplicateImportedGrammar,
validator.checkGrammarForUnusedRules,
validator.checkGrammarTypeInfer,
validator.checkClashingTerminalNames,
],
GrammarImport: validator.checkPackageImport,
CharacterRange: validator.checkInvalidCharacterRange,
Interface: [
validator.checkTypeReservedName,
validator.checkInterfacePropertyTypes,
],
Type: [
validator.checkTypeReservedName,
],
TypeAttribute: validator.checkTypeReservedName,
RuleCall: [
validator.checkUsedHiddenTerminalRule,
validator.checkUsedFragmentTerminalRule,
validator.checkRuleCallParameters,
validator.checkMultiRuleCallsAreAssigned,
validator.checkPredicateNotSupported
],
TerminalRuleCall: validator.checkUsedHiddenTerminalRule,
CrossReference: [
validator.checkCrossReferenceSyntax,
validator.checkCrossRefNameAssignment,
validator.checkCrossRefTerminalType,
validator.checkCrossRefType,
validator.checkCrossReferenceToTypeUnion
],
SimpleType: validator.checkFragmentsInTypes,
ReferenceType: validator.checkReferenceTypeUnion,
RegexToken: [
validator.checkInvalidRegexFlags,
validator.checkDirectlyUsedRegexFlags
],
Group: validator.checkPredicateNotSupported
};
registry.register(checks, validator);
}
export var IssueCodes;
(function (IssueCodes) {
IssueCodes.GrammarNameUppercase = 'grammar-name-uppercase';
IssueCodes.RuleNameUppercase = 'rule-name-uppercase';
IssueCodes.UseRegexTokens = 'use-regex-tokens';
IssueCodes.EntryRuleTokenSyntax = 'entry-rule-token-syntax';
IssueCodes.CrossRefTokenSyntax = 'cross-ref-token-syntax';
IssueCodes.ParserRuleToTypeDecl = 'parser-rule-to-type-decl';
IssueCodes.UnnecessaryFileExtension = 'unnecessary-file-extension';
IssueCodes.InvalidReturns = 'invalid-returns';
IssueCodes.InvalidInfers = 'invalid-infers';
IssueCodes.MissingInfer = 'missing-infer';
IssueCodes.MissingReturns = 'missing-returns';
IssueCodes.MissingCrossRefTerminal = 'missing-cross-ref-terminal';
IssueCodes.SuperfluousInfer = 'superfluous-infer';
IssueCodes.OptionalUnorderedGroup = 'optional-unordered-group';
IssueCodes.ParsingRuleEmpty = 'parsing-rule-empty';
})(IssueCodes || (IssueCodes = {}));
export class LangiumGrammarValidator {
constructor(services) {
this.options = {};
this.references = services.references.References;
this.nodeLocator = services.workspace.AstNodeLocator;
this.documents = services.shared.workspace.LangiumDocuments;
}
checkGrammarName(grammar, accept) {
if (grammar.name) {
const firstChar = grammar.name.substring(0, 1);
if (firstChar.toUpperCase() !== firstChar) {
accept('warning', 'Grammar name should start with an upper case letter.', {
node: grammar,
property: 'name',
data: diagnosticData(IssueCodes.GrammarNameUppercase)
});
}
// this grammar and all its transitively imported grammars need to have different names
for (const otherGrammar of resolveTransitiveImports(this.documents, grammar) /* never contains the given initial grammar! */) {
if (otherGrammar.name === grammar.name) {
accept('error', `This grammar name '${grammar.name}' is also used by the grammar in '${UriUtils.basename(otherGrammar.$document.uri)}'.`, {
node: grammar,
property: 'name',
});
}
}
}
}
checkEntryGrammarRule(grammar, accept) {
if (grammar.isDeclared && !grammar.name) {
// Incomplete syntax: grammar without a name.
return;
}
const entryRules = grammar.rules.filter(e => ast.isParserRule(e) && e.entry);
if (grammar.isDeclared && entryRules.length === 0) {
const possibleEntryRule = grammar.rules.find(e => ast.isParserRule(e) && !isDataTypeRule(e));
if (possibleEntryRule) {
accept('error', 'The grammar is missing an entry parser rule. This rule can be an entry one.', {
node: possibleEntryRule,
property: 'name',
data: diagnosticData(IssueCodes.EntryRuleTokenSyntax)
});
}
else {
accept('error', 'This grammar is missing an entry parser rule.', { node: grammar, property: 'name' });
}
}
else if (!grammar.isDeclared && entryRules.length >= 1) {
entryRules.forEach(rule => accept('error', 'Cannot declare entry rules for unnamed grammars.', { node: rule, property: 'name' }));
}
else if (entryRules.length > 1) {
entryRules.forEach(rule => accept('error', 'The entry rule has to be unique.', { node: rule, property: 'name' }));
}
else if (entryRules.length === 1 && isDataTypeRule(entryRules[0])) {
accept('error', 'The entry rule cannot be a data type rule.', { node: entryRules[0], property: 'name' });
}
}
/**
* Check whether any rule defined in this grammar is a duplicate of an already defined rule or an imported rule
*/
checkUniqueRuleName(grammar, accept) {
const extractor = (grammar) => stream(grammar.rules).filter(rule => !isEmptyRule(rule));
this.checkUniqueName(grammar, accept, extractor, 'rule');
}
/**
* Check whether any type defined in this grammar is a duplicate of an already defined type or an imported type
*/
checkUniqueTypeName(grammar, accept) {
const extractor = (grammar) => stream(grammar.types).concat(grammar.interfaces);
this.checkUniqueName(grammar, accept, extractor, 'type');
}
checkUniqueName(grammar, accept, extractor, uniqueObjName) {
const map = new MultiMap();
extractor(grammar).forEach(e => map.add(e.name, e));
for (const [, types] of map.entriesGroupedByKey()) {
if (types.length > 1) {
types.forEach(e => {
accept('error', `A ${uniqueObjName}'s name has to be unique.`, { node: e, property: 'name' });
});
}
}
const imported = new Set();
const resolvedGrammars = resolveTransitiveImports(this.documents, grammar);
for (const resolvedGrammar of resolvedGrammars) {
extractor(resolvedGrammar).forEach(e => imported.add(e.name));
}
for (const name of map.keys()) {
if (imported.has(name)) {
const types = map.get(name);
types.forEach(e => {
accept('error', `A ${uniqueObjName} with the name '${e.name}' already exists in an imported grammar.`, { node: e, property: 'name' });
});
}
}
}
// ensures for the set of transitively imported grammars that all their resulting types have names which are different compared to the given input grammar name
checkUniqueTypeAndGrammarNames(inputGrammar, accept) {
// Collect all relevant grammars. Grammars without name are ignored.
const allGrammars = [inputGrammar, ...resolveTransitiveImports(this.documents, inputGrammar)].filter(g => g.name);
// Try to find types which are declared in or inferred by the current grammar and all its transitively imported grammars, which have the same name as one of the grammars.
// Reuse precomputed types in order not to `streamAllContents` of all grammars to improve performance.
// (`streamContents` is not sufficient since actions might infer new types, but are no top-level elements!)
// Since there are validations which ensure that types and rules have unique names, multiple resulting types with the same name don't need to be considered here.
const declaredOrInferredTypes = inputGrammar.$document.validationResources?.typeToValidationInfo ?? new Map();
for (const grammar of allGrammars) {
const type = declaredOrInferredTypes.get(grammar.name);
if (type !== undefined) {
if (isDeclared(type)) { // Type, Interface
reportNonUniqueName(grammar, type.declaredNode, 'name');
}
if (isInferred(type)) {
for (const node of type.inferredNodes) {
if (ast.isParserRule(node)) { // ParserRule
reportNonUniqueName(grammar, node, 'name');
}
else if (ast.isAction(node)) { // Action
if (node.inferredType) {
reportNonUniqueName(grammar, node, 'inferredType');
}
else {
reportNonUniqueName(grammar, node, 'type');
}
}
else {
assertUnreachable(node);
}
}
}
}
}
function reportNonUniqueName(notUniquelyNamedGrammar, node, property) {
const nodeGrammar = getContainerOfType(node, ast.isGrammar);
if (nodeGrammar === inputGrammar) {
// the node is in the current grammar => report the issue at the node
if (notUniquelyNamedGrammar === inputGrammar) {
// the own grammar has the critical name
accept('error', `'${notUniquelyNamedGrammar.name}' is already used here as grammar name.`, { node, property });
}
else {
// the grammar with the critical name is transitively imported
accept('error', `'${notUniquelyNamedGrammar.name}' is already used as grammar name in '${UriUtils.basename(notUniquelyNamedGrammar.$document.uri)}'.`, { node, property });
}
}
else {
// the node is transitively imported => it is not possible to report the issue at the node
if (nodeGrammar === notUniquelyNamedGrammar) {
// the name of the node is in conflict with the name of its own grammar => report the issue at the node when validating the node's grammar => do nothing here
}
else if (notUniquelyNamedGrammar === inputGrammar) {
// the imported node is in conflict with the current grammar to validate => report the issue at the name of the current grammar
accept('error', `'${notUniquelyNamedGrammar.name}' is already used as ${node.$type} name in '${UriUtils.basename(nodeGrammar.$document.uri)}'.`, { node: inputGrammar, property: 'name' });
}
else {
// the imported node is in conflict with a transitively imported grammar => report the issue for the current grammar in general
accept('error', `'${UriUtils.basename(nodeGrammar.$document.uri)}' contains the ${node.$type} with the name '${notUniquelyNamedGrammar.name}', which is already the name of the grammar in '${UriUtils.basename(notUniquelyNamedGrammar.$document.uri)}'.`, { node: inputGrammar, keyword: 'grammar' });
}
}
}
}
checkDuplicateImportedGrammar(grammar, accept) {
const importMap = new MultiMap();
for (const imp of grammar.imports) {
const resolvedGrammar = resolveImport(this.documents, imp);
if (resolvedGrammar) {
importMap.add(resolvedGrammar, imp);
}
}
for (const [, imports] of importMap.entriesGroupedByKey()) {
if (imports.length > 1) {
imports.forEach((imp, i) => {
if (i > 0) {
accept('warning', 'The grammar is already being directly imported.', { node: imp, tags: [DiagnosticTag.Unnecessary] });
}
});
}
}
}
/**
* Compared to the validation above, this validation only checks whether two imported grammars export the same grammar rule.
*/
checkUniqueImportedRules(grammar, accept) {
const imports = new Map();
for (const imp of grammar.imports) {
const importedGrammars = resolveTransitiveImports(this.documents, imp);
imports.set(imp, importedGrammars);
}
const allDuplicates = new MultiMap();
for (const outerImport of grammar.imports) {
const outerGrammars = imports.get(outerImport);
for (const innerImport of grammar.imports) {
if (outerImport === innerImport) {
continue;
}
const innerGrammars = imports.get(innerImport);
const duplicates = this.getDuplicateExportedRules(outerGrammars, innerGrammars);
for (const duplicate of duplicates) {
allDuplicates.add(outerImport, duplicate);
}
}
}
for (const imp of grammar.imports) {
const duplicates = allDuplicates.get(imp);
if (duplicates.length > 0) {
accept('error', 'Some rules exported by this grammar are also included in other imports: ' + stream(duplicates).distinct().join(', '), { node: imp, property: 'path' });
}
}
}
getDuplicateExportedRules(outer, inner) {
const exclusiveOuter = outer.filter(g => !inner.includes(g));
const outerRules = exclusiveOuter.flatMap(e => e.rules);
const innerRules = inner.flatMap(e => e.rules);
const duplicates = new Set();
for (const outerRule of outerRules) {
const outerName = outerRule.name;
for (const innerRule of innerRules) {
const innerName = innerRule.name;
if (outerName === innerName) {
duplicates.add(innerRule.name);
}
}
}
return duplicates;
}
checkGrammarTypeInfer(grammar, accept) {
const typesOption = this.options.types || 'normal';
const types = new Set();
for (const type of grammar.types) {
types.add(type.name);
}
for (const interfaceType of grammar.interfaces) {
types.add(interfaceType.name);
}
// Collect type/interface definitions from imported grammars
for (const importedGrammar of resolveTransitiveImports(this.documents, grammar)) {
importedGrammar.types.forEach(type => types.add(type.name));
importedGrammar.interfaces.forEach(iface => types.add(iface.name));
}
for (const rule of grammar.rules.filter(ast.isAbstractParserRule)) {
if (isEmptyRule(rule)) {
continue;
}
const isDataType = ast.isParserRule(rule) && isDataTypeRule(rule);
const isInfers = !rule.returnType && !rule.dataType;
const ruleTypeName = getTypeNameWithoutError(rule);
if (typesOption === 'strict' && isInfers) {
accept('error', 'Inferred types are not allowed in strict mode.', {
node: rule.inferredType ?? rule,
property: 'name',
data: diagnosticData(IssueCodes.InvalidInfers)
});
}
else if (!isDataType && ruleTypeName && types.has(ruleTypeName) === isInfers) {
if ((isInfers || rule.returnType?.ref !== undefined) && rule.inferredType === undefined) {
// report missing returns (a type of the same name is declared)
accept('error', getMessage(ruleTypeName, isInfers), {
node: rule,
property: 'name',
data: diagnosticData(IssueCodes.MissingReturns)
});
}
else if (isInfers || rule.returnType?.ref !== undefined) {
// report bad infers (should be corrected to 'returns' to match existing type)
const infersNode = findNodeForKeyword(rule.inferredType.$cstNode, 'infers');
accept('error', getMessage(ruleTypeName, isInfers), {
node: rule.inferredType,
property: 'name',
data: {
code: IssueCodes.InvalidInfers,
actionSegment: toDocumentSegment(infersNode)
}
});
}
}
else if (isDataType && isInfers) {
const inferNode = findNodeForKeyword(rule.$cstNode, 'infer');
accept('error', 'Data type rules cannot infer a type.', {
node: rule,
property: 'inferredType',
data: {
code: IssueCodes.InvalidInfers,
actionSegment: toDocumentSegment(inferNode)
}
});
}
}
for (const action of streamAllContents(grammar).filter(ast.isAction)) {
const actionType = this.getActionType(action);
if (actionType) {
const isInfers = action.inferredType !== undefined;
const typeName = getTypeNameWithoutError(action);
if (typesOption === 'strict' && isInfers) {
accept('error', 'Inferred types are not allowed in strict mode.', {
node: action.inferredType,
property: 'name',
data: diagnosticData(IssueCodes.InvalidInfers)
});
}
else if (action.type && typeName && types.has(typeName) === isInfers) {
const keywordNode = isInfers ? findNodeForKeyword(action.$cstNode, 'infer') : findNodeForKeyword(action.$cstNode, '{');
accept('error', getMessage(typeName, isInfers), {
node: action,
property: 'type',
data: {
code: isInfers ? IssueCodes.SuperfluousInfer : IssueCodes.MissingInfer,
actionSegment: toDocumentSegment(keywordNode)
}
});
}
else if (actionType && typeName && types.has(typeName) && isInfers) {
// error: action infers type that is already defined
if (action.$cstNode) {
const inferredTypeNode = findNodeForProperty(action.inferredType?.$cstNode, 'name');
const keywordNode = findNodeForKeyword(action.$cstNode, '{');
if (inferredTypeNode && keywordNode) {
// remove everything from the opening { up to the type name
// we may lose comments in-between, but this can be undone as needed
accept('error', `${typeName} is a declared type and cannot be redefined.`, {
node: action,
property: 'type',
data: {
code: IssueCodes.SuperfluousInfer,
actionRange: {
start: keywordNode.range.end,
end: inferredTypeNode.range.start
}
}
});
}
}
}
}
}
function getMessage(name, infer) {
if (infer) {
return `The type '${name}' is already explicitly declared and cannot be inferred.`;
}
else {
return `The type '${name}' is not explicitly declared and must be inferred.`;
}
}
}
getActionType(rule) {
if (rule.type) {
return rule.type?.ref;
}
else if (rule.inferredType) {
return rule.inferredType;
}
return undefined;
}
checkHiddenTerminalRule(terminalRule, accept) {
if (terminalRule.hidden && terminalRule.fragment) {
accept('error', 'Cannot use terminal fragments as hidden tokens.', { node: terminalRule, property: 'hidden' });
}
}
checkEmptyTerminalRule(terminalRule, accept) {
try {
const regex = terminalRegex(terminalRule);
if (new RegExp(regex).test('')) {
accept('error', 'This terminal could match an empty string.', { node: terminalRule, property: 'name' });
}
}
catch {
// In case the terminal can't be transformed into a regex, we throw an error
// As this indicates unresolved cross references or parser errors, we can ignore this here
}
}
checkEmptyParserRule(parserRule, accept) {
// Rule body needs to be set;
// Entry rules and fragments may consume no input.
if (!parserRule.definition || parserRule.entry || parserRule.fragment) {
return;
}
const consumesAnything = (element) => {
// First, check cardinality of the element.
if (element.cardinality === '?' || element.cardinality === '*') {
return false;
}
// Actions themselves count as optional.
if (ast.isAction(element)) {
return false;
}
// Unordered groups act as alternatives surrounded by `*`
if (ast.isUnorderedGroup(element)) {
return false;
}
// Only one element of the group needs to consume something
if (ast.isGroup(element)) {
return element.elements.some(consumesAnything);
}
// Every altneratives needs to consume something
if (ast.isAlternatives(element)) {
return element.elements.every(consumesAnything);
}
// If the element is a direct rule call
// We need to check whether the element consumes anything
if (ast.isRuleCall(element)) {
if (ast.isInfixRule(element.rule.ref)) {
// Infix rules always at least consume their operators
return true;
}
else if (element.rule.ref?.definition) {
return consumesAnything(element.rule.ref.definition);
}
}
// Else, assert that we consume something.
return true;
};
if (!consumesAnything(parserRule.definition)) {
accept('warning', 'This parser rule potentially consumes no input.', { node: parserRule, property: 'name', code: IssueCodes.ParsingRuleEmpty });
}
}
checkInvalidRegexFlags(token, accept) {
const regex = token.regex;
if (regex) {
const slashIndex = regex.lastIndexOf('/');
const flags = regex.substring(slashIndex + 1);
// global/multiline/sticky are valid, but not supported
const unsupportedFlags = 'gmy';
// only case-insensitive/dotall/unicode are really supported
const supportedFlags = 'isu';
const allFlags = unsupportedFlags + supportedFlags;
const errorFlags = new Set();
const warningFlags = new Set();
for (let i = 0; i < flags.length; i++) {
const flag = flags.charAt(i);
if (!allFlags.includes(flag)) {
errorFlags.add(flag);
}
else if (unsupportedFlags.includes(flag)) {
warningFlags.add(flag);
}
}
const range = this.getFlagRange(token);
if (range) {
if (errorFlags.size > 0) {
accept('error', `'${Array.from(errorFlags).join('')}' ${errorFlags.size > 1 ? 'are' : 'is'} not valid regular expression flag${errorFlags.size > 1 ? 's' : ''}.`, {
node: token,
range
});
}
else if (warningFlags.size > 0) {
accept('warning', `'${Array.from(warningFlags).join('')}' regular expression flag${warningFlags.size > 1 ? 's' : ''} will be ignored by Langium.`, {
node: token,
range
});
}
}
}
}
checkDirectlyUsedRegexFlags(token, accept) {
const regex = token.regex;
if (!ast.isTerminalRule(token.$container) && regex) {
const slashIndex = regex.lastIndexOf('/');
const flags = regex.substring(slashIndex + 1);
const range = this.getFlagRange(token);
if (range && flags) {
accept('warning', 'Regular expression flags are only applied if the terminal is not a composition.', {
node: token,
range
});
}
}
}
getFlagRange(token) {
const regexCstNode = findNodeForProperty(token.$cstNode, 'regex');
if (!regexCstNode || !token.regex) {
return undefined;
}
const regex = token.regex;
const slashIndex = regex.lastIndexOf('/') + 1;
const range = {
start: {
line: regexCstNode.range.end.line,
character: regexCstNode.range.end.character - regex.length + slashIndex
},
end: regexCstNode.range.end
};
return range;
}
checkUsedHiddenTerminalRule(ruleCall, accept) {
const parentRule = getContainerOfType(ruleCall, (n) => ast.isTerminalRule(n) || ast.isParserRule(n));
if (parentRule) {
if ('hidden' in parentRule && parentRule.hidden) {
return;
}
if ('lookahead' in ruleCall && ruleCall.lookahead) {
return;
}
const terminalGroup = findLookAheadGroup(ruleCall);
if (terminalGroup && terminalGroup.lookahead) {
return;
}
const ref = ruleCall.rule.ref;
if (ast.isTerminalRule(ref) && ref.hidden) {
accept('error', 'Cannot use hidden terminal in non-hidden rule', { node: ruleCall, property: 'rule' });
}
}
}
checkUsedFragmentTerminalRule(ruleCall, accept) {
const terminal = ruleCall.rule.ref;
if (ast.isTerminalRule(terminal) && terminal.fragment) {
const parentRule = getContainerOfType(ruleCall, ast.isParserRule);
if (parentRule) {
accept('error', 'Cannot use terminal fragments as part of parser rules.', { node: ruleCall, property: 'rule' });
}
}
}
checkCrossReferenceSyntax(crossRef, accept) {
if (crossRef.deprecatedSyntax) {
accept('error', "'|' is deprecated. Please, use ':' instead.", {
node: crossRef,
property: 'deprecatedSyntax',
data: diagnosticData(IssueCodes.CrossRefTokenSyntax)
});
}
}
checkPackageImport(imp, accept) {
const resolvedGrammar = resolveImport(this.documents, imp);
if (resolvedGrammar === undefined) {
accept('error', 'Import cannot be resolved.', { node: imp, property: 'path' });
}
else if (imp.path.endsWith('.langium')) {
accept('warning', 'Imports do not need file extensions.', {
node: imp,
property: 'path',
data: diagnosticData(IssueCodes.UnnecessaryFileExtension)
});
}
}
checkInvalidCharacterRange(range, accept) {
if (range.right) {
const message = 'Character ranges cannot use more than one character';
let invalid = false;
if (range.left.value.length > 1) {
invalid = true;
accept('error', message, { node: range.left, property: 'value' });
}
if (range.right.value.length > 1) {
invalid = true;
accept('error', message, { node: range.right, property: 'value' });
}
if (!invalid) {
accept('hint', 'Consider using regex instead of character ranges', {
node: range,
data: diagnosticData(IssueCodes.UseRegexTokens)
});
}
}
}
checkGrammarForUnusedRules(grammar, accept) {
const reachableRules = getAllReachableRules(grammar, true);
const parserRulesUsedByCrossReferences = getAllRulesUsedForCrossReferences(grammar);
for (const rule of grammar.rules) {
if (ast.isTerminalRule(rule) && rule.hidden || isEmptyRule(rule)) {
continue;
}
if (!reachableRules.has(rule)) {
if (ast.isParserRule(rule) && parserRulesUsedByCrossReferences.has(rule)) {
accept('hint', 'This parser rule is not used for parsing, but referenced by cross-references. Consider to replace this rule by a type declaration.', {
node: rule,
data: diagnosticData(IssueCodes.ParserRuleToTypeDecl)
});
}
else {
accept('hint', 'This rule is declared but never referenced.', {
node: rule,
property: 'name',
tags: [DiagnosticTag.Unnecessary]
});
}
}
}
}
checkClashingTerminalNames(grammar, accept) {
const localTerminals = new MultiMap();
const localKeywords = new Set();
// Collect locally defined terminals/keywords
for (const rule of grammar.rules) {
if (ast.isTerminalRule(rule) && rule.name) {
localTerminals.add(rule.name, rule);
}
if (ast.isAbstractParserRule(rule)) {
const keywords = streamAllContents(rule).filter(ast.isKeyword);
keywords.forEach(e => localKeywords.add(e.value));
}
}
// Collect imported terminals/keywords and their respective imports
const importedTerminals = new MultiMap();
const importedKeywords = new MultiMap();
for (const importNode of grammar.imports) {
const importedGrammars = resolveTransitiveImports(this.documents, importNode);
for (const importedGrammar of importedGrammars) {
for (const rule of importedGrammar.rules) {
if (ast.isTerminalRule(rule) && rule.name) {
importedTerminals.add(rule.name, importNode);
}
else if (ast.isAbstractParserRule(rule) && rule.name) {
const keywords = streamAllContents(rule).filter(ast.isKeyword);
keywords.forEach(e => importedKeywords.add(e.value, importNode));
}
}
}
}
for (const localTerminal of localTerminals.values()) {
if (localKeywords.has(localTerminal.name)) {
// 1st case: Local terminal with local keyword (error on terminal)
accept('error', 'Terminal name clashes with existing keyword.', {
node: localTerminal,
property: 'name'
});
}
else if (importedKeywords.has(localTerminal.name)) {
const importNode = importedKeywords.get(localTerminal.name);
// 2nd case: Local terminal with imported keyword (error on terminal)
accept('error', `Terminal name clashes with imported keyword from "${importNode[0].path}".`, {
node: localTerminal,
property: 'name'
});
}
}
// Collect all imported terminals that share a name with a local keyword
const importTerminalMap = new MultiMap();
for (const localKeyword of localKeywords) {
for (const importNode of importedTerminals.get(localKeyword)) {
importTerminalMap.add(importNode, localKeyword);
}
}
for (const [importNode, keywords] of importTerminalMap.entriesGroupedByKey()) {
if (keywords.length > 0) {
// 3rd case: Imported terminal with local keyword (error on import)
accept('error', `Imported terminals (${keywords.join(', ')}) clash with locally defined keywords.`, {
node: importNode,
property: 'path'
});
}
}
// Collect all imported terminals that share a name with imported keywords
const importKeywordMap = new MultiMap();
for (const [name, imports] of importedTerminals.entriesGroupedByKey()) {
const keywordImports = importedKeywords.get(name);
if (keywordImports.length > 0) {
imports
// Exclude transitive keyword/terminal clashing
// These errors are already shown in another file
// So no need to validate these again here
.filter(e => !keywordImports.includes(e))
.forEach(e => importKeywordMap.add(e, name));
}
}
for (const [importNode, keywords] of importKeywordMap.entriesGroupedByKey()) {
if (keywords.length > 0) {
// 4th case: Imported terminal with imported keyword (error on import)
accept('error', `Imported terminals (${keywords.join(', ')}) clash with imported keywords.`, {
node: importNode,
property: 'path'
});
}
}
}
checkRuleName(rule, accept) {
if (rule.name && !isEmptyRule(rule)) {
const firstChar = rule.name.substring(0, 1);
if (firstChar.toUpperCase() !== firstChar) {
accept('warning', 'Rule name should start with an upper case letter.', {
node: rule,
property: 'name',
data: diagnosticData(IssueCodes.RuleNameUppercase)
});
}
}
}
/** This validation checks, that parser rules which are called multiple times are assigned (except for fragments). */
checkMultiRuleCallsAreAssigned(call, accept) {
const findContainerWithCardinality = (node) => {
let result = node;
while (result !== undefined) {
if (ast.isAbstractElement(result) && (result.cardinality === '+' || result.cardinality === '*')) {
break;
}
result = result.$container;
}
return result;
};
const ref = call.rule.ref;
// Parsing an unassigned terminal rule is fine.
if (!ref || ast.isTerminalRule(ref) || ast.isInfixRule(ref)) {
return;
}
// Fragment or data type rules are fine too.
if (ref.fragment || isDataTypeRule(ref)) {
return;
}
// Multiple unassigned calls if inside a data type rule is fine too.
const parentRule = getContainerOfType(call, ast.isParserRule);
if (!parentRule || isDataTypeRule(parentRule)) {
return;
}
const appearsMultipleTimes = findContainerWithCardinality(call) !== undefined;
const hasAssignment = getContainerOfType(call, ast.isAssignment) !== undefined;
if (appearsMultipleTimes && !hasAssignment) {
accept('error', `Rule call '${ref.name}' requires assignment when parsed multiple times.`, {
node: call
});
}
}
checkTypeReservedName(type, accept) {
this.checkReservedName(type, 'name', accept);
}
checkAssignmentReservedName(assignment, accept) {
this.checkReservedName(assignment, 'feature', accept);
}
checkParserRuleReservedName(rule, accept) {
if (!rule.inferredType) {
this.checkReservedName(rule, 'name', accept);
}
}
checkReservedName(node, property, accept) {
const name = node[property];
if (typeof name === 'string' && reservedNames.has(name)) {
accept('error', `'${name}' is a reserved name of the JavaScript runtime.`, {
node,
property
});
}
}
checkKeyword(keyword, accept) {
if (getContainerOfType(keyword, ast.isAbstractParserRule)) {
if (keyword.value.length === 0) {
accept('error', 'Keywords cannot be empty.', { node: keyword });
}
else if (keyword.value.trim().length === 0) {
accept('error', 'Keywords cannot only consist of whitespace characters.', { node: keyword });
}
else if (/\s/g.test(keyword.value)) {
accept('warning', 'Keywords should not contain whitespace characters.', { node: keyword });
}
}
}
checkUnorderedGroup(unorderedGroup, accept) {
unorderedGroup.elements.forEach((ele) => {
if (isOptionalCardinality(ele.cardinality)) {
accept('error', 'Optional elements in Unordered groups are currently not supported', {
node: ele,
data: diagnosticData(IssueCodes.OptionalUnorderedGroup)
});
}
});
}
checkRuleParameters(rule, accept) {
const parameters = rule.parameters;
if (parameters.length > 0) {
// Check for duplicate parameter names
const parameterNames = new MultiMap();
for (const parameter of parameters) {
parameterNames.add(parameter.name, parameter);
}
for (const [name, params] of parameterNames.entriesGroupedByKey()) {
if (params.length > 1) {
params.forEach(param => {
accept('error', `Parameter '${name}' is declared multiple times.`, {
node: param,
property: 'name'
});
});
}
}
// Check for unused parameters
const allReferences = streamAllContents(rule).filter(ast.isParameterReference);
for (const parameter of parameters) {
if (!allReferences.some(e => e.parameter.ref === parameter)) {
accept('hint', `Parameter '${parameter.name}' is unused.`, {
node: parameter,
tags: [DiagnosticTag.Unnecessary]
});
}
}
}
}
checkParserRuleDataType(rule, accept) {
if (isEmptyRule(rule)) {
return;
}
const hasDatatypeReturnType = hasDataTypeReturn(rule);
const dataTypeRule = isDataTypeRule(rule);
if (!hasDatatypeReturnType && dataTypeRule) {
accept('error', 'This parser rule does not create an object. Add a primitive return type or an action to the start of the rule to force object instantiation.', { node: rule, property: 'name' });
}
else if (hasDatatypeReturnType && !dataTypeRule) {
accept('error', 'Normal parser rules are not allowed to return a primitive value. Use a datatype rule for that.', { node: rule, property: rule.dataType ? 'dataType' : 'returnType' });
}
}
checkInfixRuleDataType(rule, accept) {
if (rule.dataType) {
accept('error', 'Infix rules are not allowed to return a primitive value.', { node: rule, property: 'dataType' });
}
}
checkAssignmentToFragmentRule(assignment, accept) {
if (!assignment.terminal) {
return;
}
if (ast.isRuleCall(assignment.terminal) && ast.isParserRule(assignment.terminal.rule.ref) && assignment.terminal.rule.ref.fragment) {
accept('error', `Cannot use fragment rule '${assignment.terminal.rule.ref.name}' for assignment of property '${assignment.feature}'.`, { node: assignment, property: 'terminal' });
}
}
checkAssignmentTypes(assignment, accept) {
if (!assignment.terminal) {
return;
}
let firstType;
const foundMixed = streamAllContents(assignment.terminal)
.map(node => ast.isCrossReference(node) ? 'ref' : 'other')
.find(type => {
if (!firstType) {
firstType = type;
return false;
}
return type !== firstType;
});
if (foundMixed) {
accept('error', this.createMixedTypeError(assignment.feature), {
node: assignment,
property: 'terminal'
});
}
}
/** This validation recursively looks at all assignments (and rewriting actions) with '=' as assignment operator and checks,
* whether the operator should be '+=' instead. */
checkOperatorMultiplicitiesForMultiAssignments(rule, accept) {
// for usual parser rules AND for fragments, but not for data type rules!
if (!rule.dataType) {
this.checkOperatorMultiplicitiesForMultiAssignmentsIndependent([rule.definition], accept);
}
}
checkOperatorMultiplicitiesForMultiAssignmentsIndependent(startNodes, accept, map = new Map()) {
// check all starting nodes
this.checkOperatorMultiplicitiesForMultiAssignmentsNested(startNodes, 1, map, accept);
// create the warnings
for (const entry of map.values()) {
if (entry.counter >= 2) {
for (const assignment of entry.assignments) {
if (assignment.operator !== '+=') {
accept('warning', `Found multiple assignments to '${assignment.feature}' with the '${assignment.operator}' assignment operator. Consider using '+=' instead to prevent data loss.`, { node: assignment, property: 'feature' } // use 'feature' instead of 'operator', since it is pretty hard to see
);
}
}
}
}
}
checkOperatorMultiplicitiesForMultiAssignmentsNested(nodes, parentMultiplicity, map, accept) {
let resultCreatedNewObject = false;
// check all given elements
for (let i = 0; i < nodes.length; i++) {
const currentNode = nodes[i];
// Tree-Rewrite-Actions are a special case: a new object is created => following assignments are put into the new object
if (ast.isAction(currentNode) && currentNode.feature) {
// (This does NOT count for unassigned actions, i.e. actions without feature name, since they change only the type of the current object.)
const mapForNewObject = new Map();
storeAssignmentUse(mapForNewObject, currentNode.feature, 1, currentNode); // remember the special rewriting feature
// all following nodes are put into the new object => check their assignments independently
this.checkOperatorMultiplicitiesForMultiAssignmentsIndependent(nodes.slice(i + 1), accept, mapForNewObject);
resultCreatedNewObject = true;
break; // breaks the current loop
}
// all other elements don't create new objects themselves:
// the current element can occur multiple times => its assignments can occur multiple times as well
let currentMultiplicity = parentMultiplicity;
if (ast.isAbstractElement(currentNode) && isArrayCardinality(currentNode.cardinality)) {
currentMultiplicity *= 2; // note that the result is not exact (but it is sufficient for the current use case)!
}
// assignment
if (ast.isAssignment(currentNode)) {
storeAssignmentUse(map, currentNode.feature, currentMultiplicity, currentNode);
}
// Search for assignments in used fragments as well, since their property values are stored in the current object.
// But do not search in calls of regular parser rules, since parser rules create new objects.
if (ast.isRuleCall(currentNode) && ast.isParserRule(currentNode.rule.ref) && currentNode.rule.ref.fragment) {
const createdNewObject = this.checkOperatorMultiplicitiesForMultiAssignmentsNested([currentNode.rule.ref.definition], currentMultiplicity, map, accept);
resultCreatedNewObject = createdNewObject || resultCreatedNewObject;
}
// look for assignments to the same feature nested within groups
if (ast.isGroup(currentNode) || ast.isUnorderedGroup(currentNode)) {
// all members of the group are relavant => collect them all
const mapGroup = new Map(); // store assignments for Alternatives separately
const createdNewObject = this.checkOperatorMultiplicitiesForMultiAssignmentsNested(currentNode.elements, 1, mapGroup, accept);
mergeAssignmentUse(mapGroup, map, createdNewObject
? (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!
: (s, t) => (s * currentMultiplicity + t) // otherwise as usual: take the current multiplicity into account
);
resultCreatedNewObject = createdNewObject || resultCreatedNewObject;
}
// for alternatives, only a single alternative is used => assume the worst case and ta