diff --git a/src/bscPlugin/transpile/BrsFilePreTranspileProcessor.ts b/src/bscPlugin/transpile/BrsFilePreTranspileProcessor.ts index 7281f2f1e..274b83296 100644 --- a/src/bscPlugin/transpile/BrsFilePreTranspileProcessor.ts +++ b/src/bscPlugin/transpile/BrsFilePreTranspileProcessor.ts @@ -1,8 +1,12 @@ -import { isLiteralExpression, isVariableExpression } from '../../astUtils/reflection'; -import { Cache } from '../../Cache'; +import { createToken } from '../../astUtils/creators'; +import { isBrsFile, isDottedGetExpression, isLiteralExpression, isVariableExpression } from '../../astUtils/reflection'; import type { BrsFile } from '../../files/BrsFile'; import type { BeforeFileTranspileEvent } from '../../interfaces'; +import { TokenKind } from '../../lexer/TokenKind'; +import type { Expression } from '../../parser/Expression'; +import { LiteralExpression } from '../../parser/Expression'; import { ParseMode } from '../../parser/Parser'; +import type { Scope } from '../../Scope'; import util from '../../util'; export class BrsFilePreTranspileProcessor { @@ -12,69 +16,93 @@ export class BrsFilePreTranspileProcessor { } public process() { - this.replaceEnumValues(); + if (isBrsFile(this.event.file)) { + this.iterateExpressions(); + } } - private replaceEnumValues() { - const membersByEnum = new Cache>(); + private iterateExpressions() { + const scope = this.event.program.getFirstScopeForFile(this.event.file); + for (let expression of this.event.file.parser.references.expressions) { + if (expression) { + this.processExpression(expression, scope); + } + } + } - const scope = this.event.file.program.getFirstScopeForFile(this.event.file); + /** + * Given a string optionally separated by dots, find an enum related to it. + * For example, all of these would return the enum: `SomeNamespace.SomeEnum.SomeMember`, SomeEnum.SomeMember, `SomeEnum` + */ + private getEnumInfo(name: string, containingNamespace: string, scope: Scope) { + //look for the enum directly + let result = scope.getEnumFileLink(name, containingNamespace); - //skip this logic if current scope has no enums and no consts - if ((scope?.getEnumMap()?.size ?? 0) === 0 && (scope?.getConstMap()?.size ?? 0) === 0) { - return; + if (result) { + return { + enum: result.item + }; } - for (const expression of this.event.file.parser.references.expressions) { - let parts: string[]; - //constants with no owner (i.e. SOME_CONST) - if (isVariableExpression(expression)) { - parts = [expression.name.text.toLowerCase()]; + //assume we've been given the enum.member syntax, so pop the member and try again + const parts = name.split('.'); + const memberName = parts.pop(); + result = scope.getEnumMap().get(parts.join('.')); + if (result) { + const value = result.item.getMemberValue(memberName); + return { + enum: result.item, + value: new LiteralExpression(createToken( + //just use float literal for now...it will transpile properly with any literal value + value.startsWith('"') ? TokenKind.StringLiteral : TokenKind.FloatLiteral, + value + )) + }; + } + } - /** - * direct enum member (i.e. Direction.up), - * namespaced enum member access (i.e. Name.Space.Direction.up), - * namespaced const access (i.e. Name.Space.SOME_CONST) or class consts (i.e. SomeClass.SOME_CONST), - */ - } else { - parts = util.getAllDottedGetParts(expression)?.map(x => x.text.toLowerCase()); - } - if (parts) { - //get the name of the member - const memberName = parts.pop(); - //get the name of the enum (including leading namespace if applicable) - const ownerName = parts.join('.'); - let containingNamespace = this.event.file.getNamespaceStatementForPosition(expression.range.start)?.getName(ParseMode.BrighterScript); + private processExpression(expression: Expression, scope: Scope) { + let containingNamespace = this.event.file.getNamespaceStatementForPosition(expression.range.start)?.getName(ParseMode.BrighterScript); - /** - * Enum member replacements - */ - const theEnum = scope.getEnumFileLink(ownerName, containingNamespace)?.item; - if (theEnum) { - const members = membersByEnum.getOrAdd(ownerName, () => theEnum.getMemberValueMap()); - const value = members?.get(memberName); - this.event.editor.overrideTranspileResult(expression, value); - continue; - } + const parts = util.splitExpression(expression); - /** - * const replacements - */ - const fullName = ownerName ? `${ownerName}.${memberName}` : memberName.toLowerCase(); + const processedNames: string[] = []; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + let entityName: string; + if (isVariableExpression(part) || isDottedGetExpression(part)) { + processedNames.push(part?.name?.text?.toLocaleLowerCase()); + entityName = processedNames.join('.'); + } else { + return; + } - const constStatement = scope.getConstFileLink(fullName, containingNamespace)?.item; + let value: Expression; - //if we found a const, override the transpile result - if (constStatement) { - this.event.editor.setProperty(expression, 'transpile', (state) => { - return isLiteralExpression(constStatement.value) - //transpile primitive value as-is - ? constStatement.value.transpile(state) - //wrap non-primitive value in parens - : ['(', ...constStatement.value.transpile(state), ')']; - }); - continue; + //did we find a const? transpile the value + let constStatement = scope.getConstFileLink(entityName, containingNamespace)?.item; + if (constStatement) { + value = constStatement.value; + } else { + //did we find an enum member? transpile that + let enumInfo = this.getEnumInfo(entityName, containingNamespace, scope); + if (enumInfo?.value) { + value = enumInfo.value; } } + + if (value) { + //override the transpile for this item. + this.event.editor.setProperty(part, 'transpile', (state) => { + if (isLiteralExpression(value)) { + return value.transpile(state); + } else { + //wrap non-literals with parens to prevent on-device compile errors + return ['(', ...value.transpile(state), ')']; + } + }); + //we are finished handling this expression + return; + } } } } diff --git a/src/bscPlugin/validation/ScopeValidator.ts b/src/bscPlugin/validation/ScopeValidator.ts index d9fbbc24c..f17c39ed4 100644 --- a/src/bscPlugin/validation/ScopeValidator.ts +++ b/src/bscPlugin/validation/ScopeValidator.ts @@ -158,15 +158,19 @@ export class ScopeValidator { const entityName = processedNames.join('.'); + //if this is an enum member, stop validating here to prevent errors further down the chain + if (scope.getEnumMemberMap().has(entityName)) { + break; + } + if ( - !scope.getEnumMemberMap().has(entityName) && !scope.getEnumMap().has(entityName) && !scope.getClassMap().has(entityName) && !scope.getConstMap().has(entityName) && !scope.getCallableByName(entityName) && !scope.namespaceLookup.has(entityName) ) { - //if this looks like an enum member, provide a nicer error message + //if this looks like an enum, provide a nicer error message const theEnum = this.getEnum(scope, entityName)?.item; if (theEnum) { this.addMultiScopeDiagnostic(event, { diff --git a/src/parser/tests/statement/ConstStatement.spec.ts b/src/parser/tests/statement/ConstStatement.spec.ts index f6b034167..d810be169 100644 --- a/src/parser/tests/statement/ConstStatement.spec.ts +++ b/src/parser/tests/statement/ConstStatement.spec.ts @@ -125,6 +125,36 @@ describe('ConstStatement', () => { end sub `); }); + + it('supports property access on complex objects', () => { + testTranspile(` + const DEFAULTS = { + enabled: true + } + sub main() + print DEFAULTS.enabled + end sub + `, ` + sub main() + print ({ + enabled: true + }).enabled + end sub + `); + }); + + it('supports calling methods on consts', () => { + testTranspile(` + const API_KEY ="ABC" + sub main() + print API_KEY.toString() + end sub + `, ` + sub main() + print "ABC".toString() + end sub + `); + }); }); describe('completions', () => { diff --git a/src/parser/tests/statement/Enum.spec.ts b/src/parser/tests/statement/Enum.spec.ts index d7d7a5379..cd6235580 100644 --- a/src/parser/tests/statement/Enum.spec.ts +++ b/src/parser/tests/statement/Enum.spec.ts @@ -973,6 +973,21 @@ describe('EnumStatement', () => { end sub `); }); - }); + it('transpiles enum values when used in complex expressions', () => { + testTranspile(` + sub main() + print Direction.up.toStr() + end sub + enum Direction + up = "up" + down = "down" + end enum + `, ` + sub main() + print "up".toStr() + end sub + `); + }); + }); }); diff --git a/src/util.ts b/src/util.ts index 897414c09..a13207e14 100644 --- a/src/util.ts +++ b/src/util.ts @@ -26,7 +26,7 @@ import type { DottedGetExpression, Expression, VariableExpression } from './pars import { Logger, LogLevel } from './Logger'; import type { Identifier, Locatable, Token } from './lexer/Token'; import { TokenKind } from './lexer/TokenKind'; -import { isDottedGetExpression, isExpression, isNamespacedVariableNameExpression, isVariableExpression } from './astUtils/reflection'; +import { isCallExpression, isCallfuncExpression, isDottedGetExpression, isExpression, isIndexedGetExpression, isNamespacedVariableNameExpression, isVariableExpression, isXmlAttributeGetExpression } from './astUtils/reflection'; import { WalkMode } from './astUtils/visitors'; import { CustomType } from './types/CustomType'; import { SourceNode } from 'source-map'; @@ -1343,6 +1343,28 @@ export class Util { return parts.reverse(); } + /** + * Break an expression into each part. + */ + public splitExpression(expression: Expression) { + const parts: Expression[] = [expression]; + let nextPart = expression; + while (nextPart) { + if (isDottedGetExpression(nextPart) || isIndexedGetExpression(nextPart) || isXmlAttributeGetExpression(nextPart)) { + nextPart = nextPart.obj; + } else if (isCallExpression(nextPart) || isCallfuncExpression(nextPart)) { + nextPart = nextPart.callee; + + } else if (isNamespacedVariableNameExpression(nextPart)) { + nextPart = nextPart.expression; + } else { + break; + } + parts.unshift(nextPart); + } + return parts; + } + /** * Returns an integer if valid, or undefined. Eliminates checking for NaN */