diff --git a/package-lock.json b/package-lock.json index cdeca5e83..1f9766e5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,6 @@ "requires": true, "packages": { "": { - "name": "brighterscript", "version": "0.42.0", "license": "MIT", "dependencies": { diff --git a/src/DiagnosticMessages.ts b/src/DiagnosticMessages.ts index 053b472e7..377f051f9 100644 --- a/src/DiagnosticMessages.ts +++ b/src/DiagnosticMessages.ts @@ -645,7 +645,13 @@ export let DiagnosticMessages = { message: `Value is required for ${expectedType} enum`, code: 1125, severity: DiagnosticSeverity.Error + }), + unknownEnumValue: (name: string, enumName: string) => ({ + message: `Enum value ${name} is not found in enum ${enumName}`, + code: 1126, + severity: DiagnosticSeverity.Error }) + }; export const DiagnosticCodeMap = {} as Record; diff --git a/src/Scope.spec.ts b/src/Scope.spec.ts index 1dab9e47b..a4888f7e5 100644 --- a/src/Scope.spec.ts +++ b/src/Scope.spec.ts @@ -845,4 +845,113 @@ describe('Scope', () => { program['scopes']['source'].buildNamespaceLookup(); }); }); + + describe('buildEnumLookup', () => { + + it('builds enum lookup', () => { + const sourceScope = program.getScopeByName('source'); + //eslint-disable-next-line @typescript-eslint/no-floating-promises + program.addOrReplaceFile('source/main.bs', ` + enum foo + bar1 + bar2 + end enum + + namespace test + function fooFace2() + end function + + class fooClass2 + end class + + enum foo2 + bar2_1 + bar2_2 + end enum + end namespace + + function fooFace() + end function + + class fooClass + end class + + enum foo3 + bar3_1 + bar3_2 + end enum + `); + // program.validate(); + let lookup = sourceScope.enumLookup; + + expect( + [...lookup.keys()] + ).to.eql([ + 'foo', + 'foo.bar1', + 'foo.bar2', + 'test.foo2', + 'test.foo2.bar2_1', + 'test.foo2.bar2_2', + 'foo3', + 'foo3.bar3_1', + 'foo3.bar3_2' + ]); + }); + }); + describe('enums', () => { + it('gets enum completions', () => { + //eslint-disable-next-line @typescript-eslint/no-floating-promises + program.addOrReplaceFile('source/main.bs', ` + enum foo + bar1 + bar2 + end enum + + sub Main() + g1 = foo.bar1 + g2 = test.foo2.bar2_1 + g3 = test.foo2.bar2_1 + g4 = test.nested.foo3.bar3_1 + b1 = foo.bad1 + b2 = test.foo2.bad2 + b4 = test.nested.foo3.bad3 + 'unknown namespace + b3 = test.foo3.bar3_1 + end sub + + namespace test + function fooFace2() + end function + + class fooClass2 + end class + + enum foo2 + bar2_1 + bar2_2 + end enum + end namespace + + function fooFace() + end function + + class fooClass + end class + + namespace test.nested + enum foo3 + bar3_1 + bar3_2 + end enum + end namespace + `); + program.validate(); + expectDiagnostics(program, [ + DiagnosticMessages.unknownEnumValue('bad1', 'foo'), + DiagnosticMessages.unknownEnumValue('bad2', 'test.foo2'), + DiagnosticMessages.unknownEnumValue('bad3', 'test.nested.foo3') + ]); + }); + }); }); diff --git a/src/Scope.ts b/src/Scope.ts index 4caa1c6ff..08d58d374 100644 --- a/src/Scope.ts +++ b/src/Scope.ts @@ -7,17 +7,18 @@ import { DiagnosticMessages } from './DiagnosticMessages'; import type { CallableContainer, BsDiagnostic, FileReference, BscFile, CallableContainerMap } from './interfaces'; import type { FileLink, Program } from './Program'; import { BsClassValidator } from './validators/ClassValidator'; -import type { NamespaceStatement, Statement, FunctionStatement, ClassStatement } from './parser/Statement'; -import type { NewExpression } from './parser/Expression'; +import type { NamespaceStatement, Statement, FunctionStatement, ClassStatement, EnumStatement } from './parser/Statement'; +import type { DottedGetExpression, NewExpression } from './parser/Expression'; import { ParseMode } from './parser/Parser'; import { standardizePath as s, util } from './util'; import { globalCallableMap } from './globalCallables'; import { Cache } from './Cache'; import { URI } from 'vscode-uri'; import { LogLevel } from './Logger'; -import { isBrsFile, isClassStatement, isFunctionStatement, isFunctionType, isXmlFile, isCustomType, isClassMethodStatement } from './astUtils/reflection'; import type { BrsFile } from './files/BrsFile'; import type { DependencyGraph, DependencyChangedEvent } from './DependencyGraph'; +import { isBrsFile, isClassMethodStatement, isClassStatement, isCustomType, isDottedGetExpression, isEnumStatement, isFunctionStatement, isFunctionType, isVariableExpression, isXmlFile } from './astUtils/reflection'; +import { createVisitor, WalkMode } from './astUtils/visitors'; /** * A class to keep track of all declarations within a given scope (like source scope, component scope) @@ -53,6 +54,12 @@ export class Scope { public get namespaceLookup() { return this.cache.getOrAdd('namespaceLookup', () => this.buildNamespaceLookup()); } + /** + * A dictionary of enums, indexed by the lower case full name of each enum. + */ + public get enumLookup() { + return this.cache.getOrAdd('enumLookup', () => this.buildEnumLookup()); + } /** * Get the class with the specified name. @@ -113,6 +120,28 @@ export class Scope { }); } + /** + * A dictionary of all enums in this scope. This includes namespaced enums always with their full name. + * The key is stored in lower case + */ + public getEnumMap(): Map> { + return this.cache.getOrAdd('enumMap', () => { + const map = new Map>(); + this.enumerateBrsFiles((file) => { + if (isBrsFile(file)) { + for (let enumStmt of file.parser.references.enumStatements) { + const lowerEnumName = enumStmt.fullName.toLowerCase(); + //only track enums with a defined name (i.e. exclude nameless malformed enums) + if (lowerEnumName) { + map.set(lowerEnumName, { item: enumStmt, file: file }); + } + } + } + }); + return map; + }); + } + /** * The list of diagnostics found specifically for this scope. Individual file diagnostics are stored on the files themselves. */ @@ -360,7 +389,8 @@ export class Scope { namespaces: new Map(), classStatements: {}, functionStatements: {}, - statements: [] + statements: [], + enumStatements: new Map() }); } } @@ -371,6 +401,8 @@ export class Scope { ns.classStatements[statement.name.text.toLowerCase()] = statement; } else if (isFunctionStatement(statement) && statement.name) { ns.functionStatements[statement.name.text.toLowerCase()] = statement; + } else if (isEnumStatement(statement) && statement.fullName) { + ns.enumStatements.set(statement.fullName.toLowerCase(), statement); } } } @@ -391,6 +423,35 @@ export class Scope { return namespaceLookup; } + public buildEnumLookup() { + let lookup = new Map(); + this.enumerateBrsFiles((file) => { + for (let [key, es] of file.parser.references.enumStatementLookup) { + if (!lookup.has(key)) { + lookup.set(key, { + file: file, + fullName: key, + nameRange: es.range, + lastPartName: es.name, + statement: es + }); + for (const ems of es.getMembers()) { + const fullMemberName = `${key}.${ems.name.toLowerCase()}`; + lookup.set(fullMemberName, { + file: file, + fullName: fullMemberName, + nameRange: ems.range, + lastPartName: ems.name, + statement: es + }); + } + } + } + }); + return lookup; + } + + public getAllNamespaceStatements() { let result = [] as NamespaceStatement[]; this.enumerateBrsFiles((file) => { @@ -467,6 +528,7 @@ export class Scope { this.diagnosticDetectFunctionCollisions(file); this.detectVariableNamespaceCollisions(file); this.diagnosticDetectInvalidFunctionExpressionTypes(file); + this.detectUnknownEnumMembers(file); }); } @@ -1001,6 +1063,46 @@ export class Scope { } return items; } + + private detectUnknownEnumMembers(file: BrsFile) { + if (!isBrsFile(file)) { + return; + } + file.parser.ast.walk(createVisitor({ + DottedGetExpression: (dge) => { + let nameParts = this.getAllDottedGetParts(dge); + let name = nameParts.pop(); + let parentPath = nameParts.join('.'); + let ec = this.enumLookup.get(parentPath); + if (ec && !this.enumLookup.has(`${parentPath}.${name}`)) { + this.diagnostics.push({ + file: file, + ...DiagnosticMessages.unknownEnumValue(name, ec.fullName), + range: dge.range, + relatedInformation: [{ + message: 'Enum declared here', + location: Location.create( + URI.file(ec.file.pathAbsolute).toString(), + ec.statement.range + ) + }] + }); + + } + } + }), { walkMode: WalkMode.visitAllRecursive }); + } + + private getAllDottedGetParts(dg: DottedGetExpression) { + let parts = [dg?.name?.text]; + let nextPart = dg.obj; + while (isDottedGetExpression(nextPart) || isVariableExpression(nextPart)) { + parts.push(nextPart?.name?.text); + nextPart = isDottedGetExpression(nextPart) ? nextPart.obj : undefined; + } + return parts.reverse(); + } + } interface NamespaceContainer { @@ -1011,9 +1113,18 @@ interface NamespaceContainer { statements: Statement[]; classStatements: Record; functionStatements: Record; + enumStatements: Map; namespaces: Map; } +interface EnumContainer { + file: BscFile; + fullName: string; + nameRange: Range; + lastPartName: string; + statement: EnumStatement; +} + interface AugmentedNewExpression extends NewExpression { file: BscFile; } diff --git a/src/files/BrsFile.spec.ts b/src/files/BrsFile.spec.ts index 3c31eb557..587f4a362 100644 --- a/src/files/BrsFile.spec.ts +++ b/src/files/BrsFile.spec.ts @@ -170,6 +170,114 @@ describe('BrsFile', () => { expect(names).to.includes('SayHello'); }); + describe('namespaces', () => { + it('gets namespace completions', () => { + program.addOrReplaceFile('source/main.bs', ` + namespace foo.bar + function sayHello() + end function + end namespace + + sub Main() + print "hello" + foo.ba + foo.bar. + end sub + `); + + let result = program.getCompletions(`${rootDir}/source/main.bs`, Position.create(8, 30)); + let names = result.map(x => x.label); + expect(names).to.includes('bar'); + + result = program.getCompletions(`${rootDir}/source/main.bs`, Position.create(9, 32)); + names = result.map(x => x.label); + expect(names).to.includes('sayHello'); + }); + }); + + describe('enums', () => { + it('gets enum completions', () => { + program.addOrReplaceFile('source/main.bs', ` + enum foo + bar1 + bar2 + end enum + + sub Main() + print "hello" + foo.ba + test.foo2.ba + end sub + + namespace test + function fooFace2() + end function + class fooClass2 + end class + + enum foo2 + bar2_1 + bar2_2 + end enum + end namespace + function fooFace() + end function + class fooClass + end class + enum foo3 + bar3_1 + bar3_2 + end enum + `); + + let result; + result = program.getCompletions(`${rootDir}/source/main.bs`, Position.create(8, 26)); + expect(result.map(x => x.label)).include.members([ + 'foo', + 'foo3', + 'fooFace', + 'fooClass' + ]); + expect(result[2].kind).to.equal(CompletionItemKind.Enum); + expect(result[3].kind).to.equal(CompletionItemKind.Enum); + + + result = program.getCompletions(`${rootDir}/source/main.bs`, Position.create(8, 27)); + expect(result.map(x => x.label)).include.members([ + 'foo', + 'foo3', + 'fooFace', + 'fooClass' + ]); + expect(result[2].kind).to.equal(CompletionItemKind.Enum); + expect(result[3].kind).to.equal(CompletionItemKind.Enum); + + result = program.getCompletions(`${rootDir}/source/main.bs`, Position.create(8, 30)); + expect(result.map(x => x.label)).include.members([ + 'bar1', + 'bar2' + ]); + expect(result[0].kind).to.equal(CompletionItemKind.EnumMember); + expect(result[1].kind).to.equal(CompletionItemKind.EnumMember); + + result = program.getCompletions(`${rootDir}/source/main.bs`, Position.create(9, 33)); + expect(result.map(x => x.label)).include.members([ + 'foo2', + 'fooFace2', + 'fooClass2' + ]); + expect(result[2].kind).to.equal(CompletionItemKind.Enum); + + result = program.getCompletions(`${rootDir}/source/main.bs`, Position.create(9, 36)); + expect(result.map(x => x.label)).include.members([ + 'bar2_1', + 'bar2_2' + ]); + expect(result[0].kind).to.equal(CompletionItemKind.EnumMember); + expect(result[1].kind).to.equal(CompletionItemKind.EnumMember); + }); + + }); it('always includes `m`', () => { //eslint-disable-next-line @typescript-eslint/no-floating-promises program.addOrReplaceFile({ src: `${rootDir}/source/main.brs`, dest: 'source/main.brs' }, ` diff --git a/src/files/BrsFile.ts b/src/files/BrsFile.ts index 8d0a59df8..7a2ae737f 100644 --- a/src/files/BrsFile.ts +++ b/src/files/BrsFile.ts @@ -23,7 +23,7 @@ import { BrsTranspileState } from '../parser/BrsTranspileState'; import { Preprocessor } from '../preprocessor/Preprocessor'; import { LogLevel } from '../Logger'; import { serializeError } from 'serialize-error'; -import { isCallExpression, isClassMethodStatement, isClassStatement, isCommentStatement, isDottedGetExpression, isFunctionExpression, isFunctionStatement, isFunctionType, isLibraryStatement, isLiteralExpression, isNamespaceStatement, isStringType, isVariableExpression, isXmlFile, isImportStatement, isClassFieldStatement } from '../astUtils/reflection'; +import { isCallExpression, isClassMethodStatement, isClassStatement, isCommentStatement, isDottedGetExpression, isFunctionExpression, isFunctionStatement, isFunctionType, isLibraryStatement, isLiteralExpression, isNamespaceStatement, isStringType, isVariableExpression, isXmlFile, isImportStatement, isClassFieldStatement, isEnumStatement } from '../astUtils/reflection'; import type { BscType } from '../types/BscType'; import { createVisitor, WalkMode } from '../astUtils/visitors'; import type { DependencyGraph } from '../DependencyGraph'; @@ -754,9 +754,17 @@ export class BrsFile { } } + //temporary workaround - the enums are not appearing on namspace, so we have to look them up first + let enumCompletions = this.getEnumStatementCompletions(currentToken, this.parseMode); let namespaceCompletions = this.getNamespaceCompletions(currentToken, this.parseMode, scope); if (namespaceCompletions.length > 0) { - return namespaceCompletions; + return [...namespaceCompletions, ...enumCompletions]; + } + + let enumMemberCompletions = this.getEnumMemberStatementCompletions(currentToken, this.parseMode); + if (enumMemberCompletions.length > 0) { + // no other completion is valid, in this case + return enumMemberCompletions; } //determine if cursor is inside a function let functionScope = this.getFunctionScopeAtPosition(position); @@ -766,7 +774,7 @@ export class BrsFile { // there's a new keyword, so only class types are viable here return [...this.getGlobalClassStatementCompletions(currentToken, this.parseMode)]; } else { - return [...KeywordCompletions, ...this.getGlobalClassStatementCompletions(currentToken, this.parseMode), ...namespaceCompletions]; + return [...KeywordCompletions, ...this.getGlobalClassStatementCompletions(currentToken, this.parseMode), ...namespaceCompletions, ...this.getEnumStatementCompletions(currentToken, this.parseMode)]; } } @@ -784,10 +792,6 @@ export class BrsFile { } if (this.isPositionNextToTokenKind(position, TokenKind.Dot)) { - if (namespaceCompletions.length > 0) { - //if we matched a namespace, after a dot, it can't be anything else but something from our namespace completions - return namespaceCompletions; - } const selfClassMemberCompletions = this.getClassMemberCompletions(position, currentToken, functionScope, scope); @@ -810,6 +814,9 @@ export class BrsFile { //include class names result.push(...classNameCompletions); + //include enums + result.push(...enumCompletions); + //include the global callables result.push(...scope.getCallablesAsCompletions(this.parseMode)); @@ -920,6 +927,61 @@ export class BrsFile { return [...results.values()]; } + private getEnumStatementCompletions(currentToken: Token, parseMode: ParseMode): CompletionItem[] { + if (parseMode === ParseMode.BrightScript) { + return []; + } + let results = new Map(); + let completionName = this.getPartialVariableName(currentToken)?.toLowerCase(); + let scopes = this.program.getScopesForFile(this); + for (let scope of scopes) { + let enumMap = scope.getEnumMap(); + for (const key of [...enumMap.keys()]) { + let es = enumMap.get(key).item; + if (es.fullName.startsWith(completionName)) { + + if (!results.has(es.fullName)) { + results.set(es.fullName, { + label: es.name, + kind: CompletionItemKind.Enum + }); + } + } + } + } + return [...results.values()]; + } + private getEnumMemberStatementCompletions(currentToken: Token, parseMode: ParseMode): CompletionItem[] { + if (parseMode === ParseMode.BrightScript) { + return []; + } + let results = new Map(); + let completionName = this.getPartialVariableName(currentToken)?.toLowerCase(); + let scopes = this.program.getScopesForFile(this); + for (let scope of scopes) { + let enumMap = scope.getEnumMap(); + for (const key of [...enumMap.keys()]) { + let enumStmt = enumMap.get(key).item; + if (completionName.startsWith(enumStmt.fullName) && completionName.length > enumStmt.fullName.length) { + + for (const member of enumStmt.getMembers()) { + const name = enumStmt.fullName + '.' + member.name; + if (name.startsWith(completionName)) { + if (!results.has(name)) { + results.set(name, { + label: member.name, + kind: CompletionItemKind.EnumMember + }); + + } + } + } + } + } + } + return [...results.values()]; + } + private getNamespaceCompletions(currentToken: Token, parseMode: ParseMode, scope: Scope): CompletionItem[] { //BrightScript does not support namespaces, so return an empty list in that case if (parseMode === ParseMode.BrightScript) { @@ -971,10 +1033,15 @@ export class BrsFile { kind: CompletionItemKind.Function }); } + } else if (isEnumStatement(stmt) && !newToken) { + if (!result.has(stmt.name)) { + result.set(stmt.name, { + label: stmt.name, + kind: CompletionItemKind.Enum + }); + } } - } - } } return [...result.values()]; diff --git a/src/parser/Statement.ts b/src/parser/Statement.ts index 8d3055484..8e03d07b8 100644 --- a/src/parser/Statement.ts +++ b/src/parser/Statement.ts @@ -2208,14 +2208,14 @@ export class EnumStatement extends Statement implements TypedefProvider { } /** - * The name of the interface (without the namespace prefix) + * The name of the enum (without the namespace prefix) */ public get name() { return this.tokens.name?.text; } /** - * The name of the interface WITH its leading namespace (if applicable) + * The name of the enum WITH its leading namespace (if applicable) */ public get fullName() { const name = this.tokens.name?.text;