diff --git a/packages/langium/src/grammar/langium-grammar-validator.ts b/packages/langium/src/grammar/langium-grammar-validator.ts index f10c45132..8bcc81286 100644 --- a/packages/langium/src/grammar/langium-grammar-validator.ts +++ b/packages/langium/src/grammar/langium-grammar-validator.ts @@ -9,7 +9,7 @@ import { Utils } from 'vscode-uri'; import { NamedAstNode } from '../references/name-provider'; import { References } from '../references/references'; import { LangiumServices } from '../services'; -import { AstNode, Reference } from '../syntax-tree'; +import { AstNode, Properties, Reference } from '../syntax-tree'; import { getContainerOfType, getDocument, streamAllContents } from '../utils/ast-util'; import { MultiMap } from '../utils/collections'; import { toDocumentSegment } from '../utils/cst-util'; @@ -29,21 +29,27 @@ export class LangiumGrammarValidationRegistry extends ValidationRegistry { super(services); const validator = services.validation.LangiumGrammarValidator; const checks: ValidationChecks = { - Action: validator.checkActionTypeUnions, + Action: [ + validator.checkActionTypeUnions, + validator.checkAssignmentReservedName + ], AbstractRule: validator.checkRuleName, Assignment: [ validator.checkAssignmentWithFeatureName, - validator.checkAssignmentToFragmentRule + validator.checkAssignmentToFragmentRule, + validator.checkAssignmentReservedName ], ParserRule: [ validator.checkParserRuleDataType, - validator.checkRuleParametersUsed + validator.checkRuleParametersUsed, + validator.checkParserRuleReservedName, ], TerminalRule: [ validator.checkTerminalRuleReturnType, validator.checkHiddenTerminalRule, validator.checkEmptyTerminalRule ], + InferredType: validator.checkTypeReservedName, Keyword: validator.checkKeyword, UnorderedGroup: validator.checkUnorderedGroup, Grammar: [ @@ -63,6 +69,9 @@ export class LangiumGrammarValidationRegistry extends ValidationRegistry { ], GrammarImport: validator.checkPackageImport, CharacterRange: validator.checkInvalidCharacterRange, + Interface: validator.checkTypeReservedName, + Type: validator.checkTypeReservedName, + TypeAttribute: validator.checkTypeReservedName, RuleCall: [ validator.checkUsedHiddenTerminalRule, validator.checkUsedFragmentTerminalRule, @@ -548,11 +557,39 @@ export class LangiumGrammarValidator { 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', code: IssueCodes.RuleNameUppercase }); + accept('warning', 'Rule name should start with an upper case letter.', { + node: rule, + property: 'name', + code: IssueCodes.RuleNameUppercase + }); } } } + checkTypeReservedName(type: ast.Interface | ast.TypeAttribute | ast.Type | ast.InferredType, accept: ValidationAcceptor): void { + this.checkReservedName(type, 'name', accept); + } + + checkAssignmentReservedName(assignment: ast.Assignment | ast.Action, accept: ValidationAcceptor): void { + this.checkReservedName(assignment, 'feature', accept); + } + + checkParserRuleReservedName(rule: ast.ParserRule, accept: ValidationAcceptor): void { + if (!rule.inferredType) { + this.checkReservedName(rule, 'name', accept); + } + } + + private checkReservedName(node: N, property: Properties, accept: ValidationAcceptor): void { + const name = node[property as keyof N]; + if (typeof name === 'string' && reservedNames.has(name)) { + accept('error', `'${name}' is a reserved name of the JavaScript runtime.`, { + node, + property + }); + } + } + checkKeyword(keyword: ast.Keyword, accept: ValidationAcceptor): void { if (keyword.value.length === 0) { accept('error', 'Keywords cannot be empty.', { node: keyword }); @@ -684,3 +721,78 @@ function isPrimitiveType(type: string): boolean { function isEmptyRule(rule: ast.AbstractRule): boolean { return !rule.definition || !rule.definition.$cstNode || rule.definition.$cstNode.length === 0; } + +const reservedNames = new Set([ + // Built-in objects, properties and methods + // Collections + 'Array', + 'Int8Array', + 'Uint8Array', + 'Uint8ClampedArray', + 'Int16Array', + 'Uint16Array', + 'Int32Array', + 'Uint32Array', + 'Float32Array', + 'Float64Array', + 'BigInt64Array', + 'BigUint64Array', + // Keyed collections + 'Map', + 'Set', + 'WeakMap', + 'WeakSet', + // Errors + 'Error', + 'AggregateError', + 'EvalError', + 'InternalError', + 'RangeError', + 'ReferenceError', + 'SyntaxError', + 'TypeError', + 'URIError', + // Primitives + 'BigInt', + 'RegExp', + 'Number', + 'Object', + 'Function', + 'Symbol', + 'String', + // Math + 'Math', + 'NaN', + 'Infinity', + 'isFinite', + 'isNaN', + // Structured data + 'Buffer', + 'ArrayBuffer', + 'SharedArrayBuffer', + 'Atomics', + 'DataView', + 'JSON', + 'globalThis', + 'decodeURIComponent', + 'decodeURI', + 'encodeURIComponent', + 'encodeURI', + 'parseInt', + 'parseFloat', + // Control abstraction + 'Promise', + 'Generator', + 'GeneratorFunction', + 'AsyncFunction', + 'AsyncGenerator', + 'AsyncGeneratorFunction', + // Reflection + 'Reflect', + 'Proxy', + // Others + 'Date', + 'Intl', + 'eval', + 'undefined' +]); diff --git a/packages/langium/test/grammar/langium-grammar-validator.test.ts b/packages/langium/test/grammar/langium-grammar-validator.test.ts index 1487b35c0..5c2b15c34 100644 --- a/packages/langium/test/grammar/langium-grammar-validator.test.ts +++ b/packages/langium/test/grammar/langium-grammar-validator.test.ts @@ -4,13 +4,13 @@ * terms of the MIT License, which is available in the project root. ******************************************************************************/ -import { createLangiumGrammarServices, EmptyFileSystem } from '../../src'; -import { Assignment, Grammar, ParserRule } from '../../src/grammar/generated/ast'; -import { expectError, validationHelper } from '../../src/test'; +import { AstNode, createLangiumGrammarServices, EmptyFileSystem, Properties, streamAllContents, GrammarAST } from '../../src'; +import { expectError, expectIssue, expectNoIssues, expectWarning, validationHelper, ValidationResult } from '../../src/test'; import { IssueCodes } from '../../src/grammar/langium-grammar-validator'; +import { DiagnosticSeverity } from 'vscode-languageserver'; const services = createLangiumGrammarServices(EmptyFileSystem); -const validate = validationHelper(services.grammar); +const validate = validationHelper(services.grammar); describe('Langium grammar validation', () => { @@ -42,7 +42,7 @@ describe('Langium grammar validation', () => { // assert expectError(validationResult, /Cannot use fragment rule 'B' for assignment of property 'b'./, { - node: (validationResult.document.parseResult.value.rules[0] as ParserRule).definition as Assignment, + node: (validationResult.document.parseResult.value.rules[0] as GrammarAST.ParserRule).definition as GrammarAST.Assignment, property: {name: 'terminal'} }); }); @@ -103,4 +103,279 @@ describe('Langium grammar validation', () => { code: IssueCodes.SuperfluousInfer }); }); -}); \ No newline at end of file +}); + +describe('checkReferenceToRuleButNotType', () => { + + const input = ` + grammar CrossRefs + + entry Model: + 'model' name=ID + (elements+=Element)*; + + type AbstractElement = Reference | string; + + Element: + Definition | Reference; + + Definition infers DefType: + name=ID; + Reference infers RefType: + ref=[Definition]; + terminal ID: /[_a-zA-Z][\\w_]*/; + `.trim(); + + let validationResult: ValidationResult; + + beforeAll(async () => { + validationResult = await validate(input); + }); + + test('CrossReference validation', () => { + const crossRef = streamAllContents(validationResult.document.parseResult.value).find(GrammarAST.isCrossReference)!; + expectError(validationResult, "Use the rule type 'DefType' instead of the typed rule name 'Definition' for cross references.", { + node: crossRef, + property: { name: 'type' } + }); + }); + + test('AtomType validation', () => { + const type = validationResult.document.parseResult.value.types[0]; + expectError(validationResult, "Use the rule type 'RefType' instead of the typed rule name 'Reference' for cross references.", { + node: type, + property: { name: 'typeAlternatives' } + }); + }); + +}); + +describe('Check Rule Fragment Validation', () => { + const grammar = ` + grammar g + type Type = Fragment; + fragment Fragment: name=ID; + terminal ID: /[_a-zA-Z][\\w_]*/; + `.trim(); + + let validationResult: ValidationResult; + + beforeAll(async () => { + validationResult = await validate(grammar); + }); + + test('Rule Fragment Validation', () => { + const range = { start: { character: 16, line: 1 }, end: { character: 24, line: 1 } }; + expectError(validationResult, 'Cannot use rule fragments in types.', { range }); + }); +}); + +describe('Checked Named CrossRefs', () => { + const input = ` + grammar g + A: 'a' name=ID; + B: 'b' name=[A]; + terminal ID: /[_a-zA-Z][\\w_]*/; + `.trim(); + + let validationResult: ValidationResult; + + beforeAll(async () => { + validationResult = await validate(input); + }); + + test('Named crossReference warning', () => { + const rule = ((validationResult.document.parseResult.value.rules[1] as GrammarAST.ParserRule).definition as GrammarAST.Group).elements[1] as GrammarAST.Assignment; + expectWarning(validationResult, 'The "name" property is not recommended for cross-references.', { + node: rule, + property: { name: 'feature' } + }); + }); +}); + +describe('Check grammar with primitives', () => { + const grammar = ` + grammar PrimGrammar + entry Expr: + (Word | Bool | Num | LargeInt | DateObj)*; + Word: + 'Word' val=STR; + Bool: + 'Bool' val?='true'; + Num: + 'Num' val=NUM; + LargeInt: + 'LargeInt' val=BIG 'n'; + DateObj: + 'Date' val=DATE; + terminal STR: /[_a-zA-Z][\\w_]*/; + terminal BIG returns bigint: /[0-9]+(?=n)/; + terminal NUM returns number: /[0-9]+(\\.[0-9])?/; + terminal DATE returns Date: /[0-9]{4}-{0-9}2-{0-9}2/+; + `.trim(); + + let validationResult: ValidationResult; + + // 1. build a parser from this grammar, verify it works + beforeAll(async () => { + validationResult = await validate(grammar); + }); + + test('No validation errors in grammar', () => { + expectNoIssues(validationResult); + }); +}); + +describe('Unordered group validations', () => { + + test('Unsupported optional element in unordered group error', async () => { + const text = ` + grammar TestUnorderedGroup + + entry Book: + 'book' name=STRING + ( + ("description" descr=STRING) + & ("edition" version=STRING)? + & ("author" author=STRING) + ) + ; + hidden terminal WS: /\\s+/; + terminal STRING: /"[^"]*"|'[^']*'/; + `; + + const validation = await validate(text); + expect(validation.diagnostics).toHaveLength(1); + const errorText = '("edition" version=STRING)?'; + const offset = validation.document.textDocument.getText().indexOf(errorText); + expectError(validation, 'Optional elements in Unordered groups are currently not supported', { offset: offset, length: errorText.length, code: IssueCodes.OptionalUnorderedGroup } ); + }); +}); + +describe('Unused rules validation', () => { + + test('Should not create validate for indirectly used terminal', async () => { + const text = ` + grammar TestUsedTerminals + + entry Used: name=ID; + hidden terminal WS: /\\s+/; + terminal ID: 'a' STRING; + terminal STRING: /"[^"]*"|'[^']*'/; + `; + const validation = await validate(text); + expectNoIssues(validation); + }); + + test('Unused terminals are correctly identified', async () => { + const text = ` + grammar TestUnusedTerminals + + entry Used: name=ID; + hidden terminal WS: /\\s+/; + terminal ID: /[_a-zA-Z][\\w_]*/; + terminal STRING: /"[^"]*"|'[^']*'/; + `; + const validation = await validate(text); + expect(validation.diagnostics).toHaveLength(1); + const stringTerminal = validation.document.parseResult.value.rules.find(e => e.name === 'STRING')!; + expectIssue(validation, { + node: stringTerminal, + property: { + name: 'name' + }, + severity: DiagnosticSeverity.Hint + }); + }); + + test('Unused parser rules are correctly identified', async () => { + const text = ` + grammar TestUnusedParserRule + + entry Used: name=ID; + Unused: name=ID; + hidden terminal WS: /\\s+/; + terminal ID: /[_a-zA-Z][\\w_]*/; + `; + const validation = await validate(text); + expect(validation.diagnostics).toHaveLength(1); + const unusedRule = validation.document.parseResult.value.rules.find(e => e.name === 'Unused')!; + expectIssue(validation, { + node: unusedRule, + property: { + name: 'name' + }, + severity: DiagnosticSeverity.Hint + }); + }); + +}); + +describe('Reserved names', () => { + + test('Reserved parser rule name', async () => { + const text = 'String: name="X";'; + expectReservedName(await validate(text), GrammarAST.isParserRule, 'name'); + }); + + test('Reserved terminal rule name - negative', async () => { + const text = 'terminal String: /X/;'; + const validation = await validate(text); + expect(validation.diagnostics).toHaveLength(0); + }); + + test('Reserved rule inferred type', async () => { + const text = 'X infers String: name="X";'; + expectReservedName(await validate(text), GrammarAST.isInferredType, 'name'); + }); + + test('Reserved assignment feature', async () => { + const text = 'X: Map="X";'; + expectReservedName(await validate(text), GrammarAST.isAssignment, 'feature'); + }); + + test('Reserved action type', async () => { + const text = 'X: {infer String} name="X";'; + expectReservedName(await validate(text), GrammarAST.isInferredType, 'name'); + }); + + test('Reserved action feature', async () => { + const text = 'X: Y {infer Z.Map=current} name="X"; Y: name="Y";'; + expectReservedName(await validate(text), GrammarAST.isAction, 'feature'); + }); + + test('Reserved interface name', async () => { + const text = 'interface String {}'; + expectReservedName(await validate(text), GrammarAST.isInterface, 'name'); + }); + + test('Reserved interface name - negative', async () => { + const text = 'interface obj {}'; + const validation = await validate(text); + expect(validation.diagnostics).toHaveLength(0); + }); + + test('Reserved type attribute name', async () => { + const text = 'interface X { Map: number }'; + expectReservedName(await validate(text), GrammarAST.isTypeAttribute, 'name'); + }); + + test('Reserved type name', async () => { + const text = 'type String = X; X: name="X";'; + expectReservedName(await validate(text), GrammarAST.isType, 'name'); + }); + + function expectReservedName(validation: ValidationResult, predicate: (node: AstNode) => node is T, property: Properties): void { + expect(validation.diagnostics).toHaveLength(1); + const node = streamAllContents(validation.document.parseResult.value).find(predicate)!; + expectIssue(validation, { + node, + message: / is a reserved name of the JavaScript runtime\.$/, + property: { + name: property + }, + severity: DiagnosticSeverity.Error + }); + } + +}); diff --git a/packages/langium/test/validation/grammar-validation.test.ts b/packages/langium/test/validation/grammar-validation.test.ts deleted file mode 100644 index 328b273d2..000000000 --- a/packages/langium/test/validation/grammar-validation.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -/****************************************************************************** - * Copyright 2022 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 { DiagnosticSeverity } from 'vscode-languageserver'; -import { createLangiumGrammarServices, EmptyFileSystem } from '../../src'; -import { Assignment, CrossReference, Grammar, Group, ParserRule } from '../../src/grammar/generated/ast'; -import { IssueCodes } from '../../src/grammar/langium-grammar-validator'; -import { expectError, expectIssue, expectNoIssues, expectWarning, validationHelper, ValidationResult } from '../../src/test'; - -const services = createLangiumGrammarServices(EmptyFileSystem); -const validate = validationHelper(services.grammar); - -describe('checkReferenceToRuleButNotType', () => { - - const input = ` - grammar CrossRefs - - entry Model: - 'model' name=ID - (elements+=Element)*; - - type AbstractElement = Reference | string; - - Element: - Definition | Reference; - - Definition infers DefType: - name=ID; - Reference infers RefType: - ref=[Definition]; - terminal ID: /[_a-zA-Z][\\w_]*/; - `.trim(); - - let validationResult: ValidationResult; - - beforeAll(async () => { - validationResult = await validate(input); - }); - - test('CrossReference validation', () => { - const rule = ((validationResult.document.parseResult.value.rules[3] as ParserRule).definition as Assignment).terminal as CrossReference; - expectError(validationResult, "Use the rule type 'DefType' instead of the typed rule name 'Definition' for cross references.", { - node: rule, - property: { name: 'type' } - }); - }); - - test('AtomType validation', () => { - const type = validationResult.document.parseResult.value.types[0]; - expectError(validationResult, "Use the rule type 'RefType' instead of the typed rule name 'Reference' for cross references.", { - node: type, - property: { name: 'typeAlternatives' } - }); - }); - -}); - -describe('Check Rule Fragment Validation', () => { - const grammar = ` - grammar g - type Type = Fragment; - fragment Fragment: name=ID; - terminal ID: /[_a-zA-Z][\\w_]*/; - `.trim(); - - let validationResult: ValidationResult; - - beforeAll(async () => { - validationResult = await validate(grammar); - }); - - test('Rule Fragment Validation', () => { - const range = { start: { character: 16, line: 1 }, end: { character: 24, line: 1 } }; - expectError(validationResult, 'Cannot use rule fragments in types.', { range }); - }); -}); - -describe('Checked Named CrossRefs', () => { - const input = ` - grammar g - A: 'a' name=ID; - B: 'b' name=[A]; - terminal ID: /[_a-zA-Z][\\w_]*/; - `.trim(); - - let validationResult: ValidationResult; - - beforeAll(async () => { - validationResult = await validate(input); - }); - - test('Named crossReference warning', () => { - const rule = ((validationResult.document.parseResult.value.rules[1] as ParserRule).definition as Group).elements[1] as Assignment; - expectWarning(validationResult, 'The "name" property is not recommended for cross-references.', { - node: rule, - property: { name: 'feature' } - }); - }); -}); - -describe('Check grammar with primitives', () => { - const grammar = ` - grammar PrimGrammar - entry Expr: - (String | Bool | Num | BigInt | DateObj)*; - String: - 'String' val=STR; - Bool: - 'Bool' val?='true'; - Num: - 'Num' val=NUM; - BigInt: - 'BigInt' val=BIG 'n'; - DateObj: - 'Date' val=DATE; - terminal STR: /[_a-zA-Z][\\w_]*/; - terminal BIG returns bigint: /[0-9]+(?=n)/; - terminal NUM returns number: /[0-9]+(\\.[0-9])?/; - terminal DATE returns Date: /[0-9]{4}-{0-9}2-{0-9}2/+; - `.trim(); - - let validationResult: ValidationResult; - - // 1. build a parser from this grammar, verify it works - beforeAll(async () => { - validationResult = await validate(grammar); - }); - - test('No validation errors in grammar', () => { - expectNoIssues(validationResult); - }); -}); - -describe('Unordered group validations', () => { - - test('Unsupported optional element in unordered group error', async () => { - const text = ` - grammar TestUnorderedGroup - - entry Book: - 'book' name=STRING - ( - ("description" descr=STRING) - & ("edition" version=STRING)? - & ("author" author=STRING) - ) - ; - hidden terminal WS: /\\s+/; - terminal STRING: /"[^"]*"|'[^']*'/; - `; - - const validation = await validate(text); - expect(validation.diagnostics).toHaveLength(1); - const errorText = '("edition" version=STRING)?'; - const offset = validation.document.textDocument.getText().indexOf(errorText); - expectError(validation, 'Optional elements in Unordered groups are currently not supported', { offset: offset, length: errorText.length, code: IssueCodes.OptionalUnorderedGroup } ); - }); -}); - -describe('Unused rules validation', () => { - - test('Should not create validate for indirectly used terminal', async () => { - const text = ` - grammar TestUsedTerminals - - entry Used: name=ID; - hidden terminal WS: /\\s+/; - terminal ID: 'a' STRING; - terminal STRING: /"[^"]*"|'[^']*'/; - `; - const validation = await validate(text); - expectNoIssues(validation); - }); - - test('Unused terminals are correctly identified', async () => { - const text = ` - grammar TestUnusedTerminals - - entry Used: name=ID; - hidden terminal WS: /\\s+/; - terminal ID: /[_a-zA-Z][\\w_]*/; - terminal STRING: /"[^"]*"|'[^']*'/; - `; - const validation = await validate(text); - expect(validation.diagnostics).toHaveLength(1); - const stringTerminal = validation.document.parseResult.value.rules.find(e => e.name === 'STRING')!; - expectIssue(validation, { - node: stringTerminal, - property: { - name: 'name' - }, - severity: DiagnosticSeverity.Hint - }); - }); - - test('Unused parser rules are correctly identified', async () => { - const text = ` - grammar TestUnusedParserRule - - entry Used: name=ID; - Unused: name=ID; - hidden terminal WS: /\\s+/; - terminal ID: /[_a-zA-Z][\\w_]*/; - `; - const validation = await validate(text); - expect(validation.diagnostics).toHaveLength(1); - const unusedRule = validation.document.parseResult.value.rules.find(e => e.name === 'Unused')!; - expectIssue(validation, { - node: unusedRule, - property: { - name: 'name' - }, - severity: DiagnosticSeverity.Hint - }); - }); - -});