Repository: rokucommunity/brighterscript
Pull request #632 (2022-07-05 14:58:10)

New Language Feature: Constants
Adds support for defining compile-time constants. These are replaced at usage locations at runtime, and work very similarly to enums.


Patch:
diff --git a/docs/constants.md b/docs/constants.md
new file mode 100644
index 00000000..894ed03c
--- /dev/null
+++ b/docs/constants.md
@@ -0,0 +1,112 @@
+# Constants
+You can use the `const` keyword to declare constant values. These values cannot be changed at runtime. Constant references will be inlined at compile-time, which is very efficient (similar to how [enums](enums.md) work).
+
+**NOTE:** These are not to be confused with the conditional compile `#const` statements, which are a totally different thing.
+
+## Basic usage
+Consts can be defined at the root level of files, and are scoped the same way as functions.
+```BrighterScript
+const GREETING_MESSAGE = "Hello world"
+sub main()
+    print GREETING_MESSAGE
+end sub
+```
+transpiles to
+```BrightScript
+sub main()
+    print "Hello world"
+end sub
+```
+
+## Namespaced Consts
+Consts can be defined within namespaces, and accessed like a property.
+
+```BrighterScript
+namespace networking
+    const BASE_URL = "https://your-app.com/api/v1"
+    sub printBaseUrl()
+		'namespace-relative access is allowed
+        print BASE_URL
+    end sub
+end namespace
+sub main()
+    print networking.BASE_URL
+end sub
+```
+
+transpiles to
+
+```BrightScript
+sub networking_printBaseUrl()
+    'namespace-relative access is allowed
+    print "https://your-app.com/api/v1"
+end sub
+
+sub main()
+    print "https://your-app.com/api/v1"
+end sub
+```
+
+## Complex objects
+Constants can contain complex objects or arrays as well, but keep in mind that these are inlined at compile-time, so it is not recommended to reach into these objects for specific values all the time.
+
+non-primitive const values will be wrapped with parentheses when transpiled.
+
+```BrighterScript
+namespace networking
+	const HEADERS = {
+		"Authorization": "Basic",
+		"Access-Control-Allow-Credentials": "true"
+	}
+	const METHODS = [
+	    "GET",
+	    "POST",
+	    "PUT",
+	    "DELETE"
+	]
+end namespace
+sub main()
+    print networking.HEADERS
+    print networking.METHODS
+end sub
+```
+**Transpiled:**
+```brightscript
+sub main()
+    print ({
+        "Authorization": "Basic"
+        "Access-Control-Allow-Credentials": "true"
+    })
+    print ([
+        "GET"
+        "POST"
+        "PUT"
+        "DELETE"
+    ])
+end sub
+```
+
+## Referencing other constants
+Constants can reference other constants (as long as there is no circular reference). You can even use template strings!
+
+```brighterscript
+const BASE_URL = "https://api.domain.com"
+const LOGIN_PATH = "/login"
+
+const LOGIN_URL = BASE_URL + LOGIN_PATH
+
+const ERROR_URL = `${BASE_URL}/some/error`
+sub main()
+    print LOGIN_URL
+    print ERROR_URL
+end sub
+```
+
+transpiles to
+
+```brightscript
+sub main()
+    print ("https://api.domain.com" + "/login")
+    print (bslib_toString("https://api.domain.com") + "/some/error")
+end sub
+```
diff --git a/src/Program.ts b/src/Program.ts
index 4df9a9b3..8032603a 100644
--- a/src/Program.ts
+++ b/src/Program.ts
@@ -849,8 +849,8 @@ export class Program {
             }
             //no other result is possible in this case
             return result;
-
         }
+
         //find the scopes for this file
         let scopes = this.getScopesForFile(file);
 
diff --git a/src/Scope.ts b/src/Scope.ts
index 2ae549c6..2a1b0bbd 100644
--- a/src/Scope.ts
+++ b/src/Scope.ts
@@ -7,7 +7,7 @@ import { DiagnosticMessages } from './DiagnosticMessages';
 import type { CallableContainer, BsDiagnostic, FileReference, BscFile, CallableContainerMap, FileLink } from './interfaces';
 import type { Program } from './Program';
 import { BsClassValidator } from './validators/ClassValidator';
-import type { NamespaceStatement, Statement, FunctionStatement, ClassStatement, EnumStatement, InterfaceStatement, EnumMemberStatement } from './parser/Statement';
+import type { NamespaceStatement, Statement, FunctionStatement, ClassStatement, EnumStatement, InterfaceStatement, EnumMemberStatement, ConstStatement } from './parser/Statement';
 import type { NewExpression } from './parser/Expression';
 import { ParseMode } from './parser/Parser';
 import { standardizePath as s, util } from './util';
@@ -17,7 +17,7 @@ import { URI } from 'vscode-uri';
 import { LogLevel } from './Logger';
 import type { BrsFile } from './files/BrsFile';
 import type { DependencyGraph, DependencyChangedEvent } from './DependencyGraph';
-import { isBrsFile, isClassMethodStatement, isClassStatement, isCustomType, isEnumStatement, isFunctionStatement, isFunctionType, isXmlFile } from './astUtils/reflection';
+import { isBrsFile, isClassMethodStatement, isClassStatement, isConstStatement, isCustomType, isEnumStatement, isFunctionStatement, isFunctionType, isXmlFile } from './astUtils/reflection';
 import { SymbolTable } from './SymbolTable';
 
 /**
@@ -140,6 +140,25 @@ export class Scope {
         return enumeration;
     }
 
+    /**
+     * Get a constant and its containing file by the constant name
+     * @param constName - The constant name, including the namespace of the constant if possible
+     * @param containingNamespace - The namespace used to resolve relative constant names. (i.e. the namespace around the current statement trying to find a constant)
+     */
+    public getConstFileLink(constName: string, containingNamespace?: string): FileLink<ConstStatement> {
+        const lowerName = constName?.toLowerCase();
+        const constMap = this.getConstMap();
+
+        let result = constMap.get(
+            util.getFullyQualifiedClassName(lowerName, containingNamespace?.toLowerCase())
+        );
+        //if we couldn't find the constant by its full namespaced name, look for a global constant with that name
+        if (!result) {
+            result = constMap.get(lowerName);
+        }
+        return result;
+    }
+
     /**
      * Get a map of all enums by their member name.
      * The keys are lower-case fully-qualified paths to the enum and its member. For example:
@@ -248,6 +267,26 @@ export class Scope {
         });
     }
 
+    /**
+     * A dictionary of all constants in this scope. This includes namespaced constants always with their full name.
+     * The key is stored in lower case
+     */
+    public getConstMap(): Map<string, FileLink<ConstStatement>> {
+        return this.cache.getOrAdd('constMap', () => {
+            const map = new Map<string, FileLink<ConstStatement>>();
+            this.enumerateBrsFiles((file) => {
+                for (let stmt of file.parser.references.constStatements) {
+                    const lowerEnumName = stmt.fullName.toLowerCase();
+                    //only track enums with a defined name (i.e. exclude nameless malformed enums)
+                    if (lowerEnumName) {
+                        map.set(lowerEnumName, { item: stmt, file: file });
+                    }
+                }
+            });
+            return map;
+        });
+    }
+
     /**
      * The list of diagnostics found specifically for this scope. Individual file diagnostics are stored on the files themselves.
      */
@@ -494,10 +533,11 @@ export class Scope {
                             fullName: loopName,
                             nameRange: namespaceStatement.nameExpression.range,
                             lastPartName: part,
-                            namespaces: new Map<string, NamespaceContainer>(),
+                            namespaces: new Map(),
                             classStatements: {},
                             functionStatements: {},
-                            enumStatements: new Map<string, EnumStatement>(),
+                            enumStatements: new Map(),
+                            constStatements: new Map(),
                             statements: [],
                             symbolTable: new SymbolTable(this.symbolTable)
                         });
@@ -512,6 +552,8 @@ export class Scope {
                         ns.functionStatements[statement.name.text.toLowerCase()] = statement;
                     } else if (isEnumStatement(statement) && statement.fullName) {
                         ns.enumStatements.set(statement.fullName.toLowerCase(), statement);
+                    } else if (isConstStatement(statement) && statement.fullName) {
+                        ns.constStatements.set(statement.fullName.toLowerCase(), statement);
                     }
                 }
                 // Merges all the symbol tables of the namespace statements into the new symbol table created above.
@@ -1157,6 +1199,7 @@ interface NamespaceContainer {
     classStatements: Record<string, ClassStatement>;
     functionStatements: Record<string, FunctionStatement>;
     enumStatements: Map<string, EnumStatement>;
+    constStatements: Map<string, ConstStatement>;
     namespaces: Map<string, NamespaceContainer>;
     symbolTable: SymbolTable;
 }
diff --git a/src/astUtils/reflection.ts b/src/astUtils/reflection.ts
index 5a95570d..31b08337 100644
--- a/src/astUtils/reflection.ts
+++ b/src/astUtils/reflection.ts
@@ -1,4 +1,4 @@
-import type { Body, AssignmentStatement, Block, ExpressionStatement, CommentStatement, ExitForStatement, ExitWhileStatement, FunctionStatement, IfStatement, IncrementStatement, PrintStatement, GotoStatement, LabelStatement, ReturnStatement, EndStatement, StopStatement, ForStatement, ForEachStatement, WhileStatement, DottedSetStatement, IndexedSetStatement, LibraryStatement, NamespaceStatement, ImportStatement, ClassFieldStatement, ClassMethodStatement, ClassStatement, Statement, InterfaceFieldStatement, InterfaceMethodStatement, InterfaceStatement, EnumStatement, EnumMemberStatement, TryCatchStatement, CatchStatement, MethodStatement, FieldStatement } from '../parser/Statement';
+import type { Body, AssignmentStatement, Block, ExpressionStatement, CommentStatement, ExitForStatement, ExitWhileStatement, FunctionStatement, IfStatement, IncrementStatement, PrintStatement, GotoStatement, LabelStatement, ReturnStatement, EndStatement, StopStatement, ForStatement, ForEachStatement, WhileStatement, DottedSetStatement, IndexedSetStatement, LibraryStatement, NamespaceStatement, ImportStatement, ClassFieldStatement, ClassMethodStatement, ClassStatement, Statement, InterfaceFieldStatement, InterfaceMethodStatement, InterfaceStatement, EnumStatement, EnumMemberStatement, TryCatchStatement, CatchStatement, MethodStatement, FieldStatement, ConstStatement } from '../parser/Statement';
 import type { LiteralExpression, Expression, BinaryExpression, CallExpression, FunctionExpression, NamespacedVariableNameExpression, DottedGetExpression, XmlAttributeGetExpression, IndexedGetExpression, GroupingExpression, EscapedCharCodeLiteralExpression, ArrayLiteralExpression, AALiteralExpression, UnaryExpression, VariableExpression, SourceLiteralExpression, NewExpression, CallfuncExpression, TemplateStringQuasiExpression, TemplateStringExpression, TaggedTemplateStringExpression, AnnotationExpression, FunctionParameterExpression, AAMemberExpression } from '../parser/Expression';
 import type { BrsFile } from '../files/BrsFile';
 import type { XmlFile } from '../files/XmlFile';
@@ -158,6 +158,9 @@ export function isEnumStatement(element: Statement | Expression | undefined): el
 export function isEnumMemberStatement(element: Statement | Expression | undefined): element is EnumMemberStatement {
     return element?.constructor.name === 'EnumMemberStatement';
 }
+export function isConstStatement(element: Statement | Expression | undefined): element is ConstStatement {
+    return element?.constructor.name === 'ConstStatement';
+}
 export function isTryCatchStatement(element: Statement | Expression | undefined): element is TryCatchStatement {
     return element?.constructor.name === 'TryCatchStatement';
 }
diff --git a/src/astUtils/visitors.ts b/src/astUtils/visitors.ts
index 041e14fa..1eeba70a 100644
--- a/src/astUtils/visitors.ts
+++ b/src/astUtils/visitors.ts
@@ -1,6 +1,6 @@
 /* eslint-disable no-bitwise */
 import type { CancellationToken } from 'vscode-languageserver';
-import type { Statement, Body, AssignmentStatement, Block, ExpressionStatement, CommentStatement, ExitForStatement, ExitWhileStatement, FunctionStatement, IfStatement, IncrementStatement, PrintStatement, GotoStatement, LabelStatement, ReturnStatement, EndStatement, StopStatement, ForStatement, ForEachStatement, WhileStatement, DottedSetStatement, IndexedSetStatement, LibraryStatement, NamespaceStatement, ImportStatement, ClassStatement, ClassMethodStatement, ClassFieldStatement, EnumStatement, EnumMemberStatement, DimStatement, TryCatchStatement, CatchStatement, ThrowStatement, InterfaceStatement, InterfaceFieldStatement, InterfaceMethodStatement, FieldStatement, MethodStatement } from '../parser/Statement';
+import type { Statement, Body, AssignmentStatement, Block, ExpressionStatement, CommentStatement, ExitForStatement, ExitWhileStatement, FunctionStatement, IfStatement, IncrementStatement, PrintStatement, GotoStatement, LabelStatement, ReturnStatement, EndStatement, StopStatement, ForStatement, ForEachStatement, WhileStatement, DottedSetStatement, IndexedSetStatement, LibraryStatement, NamespaceStatement, ImportStatement, ClassStatement, ClassMethodStatement, ClassFieldStatement, EnumStatement, EnumMemberStatement, DimStatement, TryCatchStatement, CatchStatement, ThrowStatement, InterfaceStatement, InterfaceFieldStatement, InterfaceMethodStatement, FieldStatement, MethodStatement, ConstStatement } from '../parser/Statement';
 import type { AALiteralExpression, AAMemberExpression, AnnotationExpression, ArrayLiteralExpression, BinaryExpression, CallExpression, CallfuncExpression, DottedGetExpression, EscapedCharCodeLiteralExpression, Expression, FunctionExpression, FunctionParameterExpression, GroupingExpression, IndexedGetExpression, LiteralExpression, NamespacedVariableNameExpression, NewExpression, NullCoalescingExpression, RegexLiteralExpression, SourceLiteralExpression, TaggedTemplateStringExpression, TemplateStringExpression, TemplateStringQuasiExpression, TernaryExpression, UnaryExpression, VariableExpression, XmlAttributeGetExpression } from '../parser/Expression';
 import { isExpression, isStatement } from './reflection';
 import type { AstEditor } from './AstEditor';
@@ -133,6 +133,7 @@ export function createVisitor(
         ThrowStatement?: (statement: ThrowStatement, parent?: Statement, owner?: any, key?: any) => Statement | void;
         EnumStatement?: (statement: EnumStatement, parent?: Statement, owner?: any, key?: any) => Statement | void;
         EnumMemberStatement?: (statement: EnumMemberStatement, parent?: Statement, owner?: any, key?: any) => Statement | void;
+        ConstStatement?: (statement: ConstStatement, parent?: Statement, owner?: any, key?: any) => Statement | void;
         //expressions
         BinaryExpression?: (expression: BinaryExpression, parent?: Statement | Expression, owner?: any, key?: any) => Expression | void;
         CallExpression?: (expression: CallExpression, parent?: Statement | Expression, owner?: any, key?: any) => Expression | void;
diff --git a/src/bscPlugin/transpile/BrsFilePreTranspileProcessor.ts b/src/bscPlugin/transpile/BrsFilePreTranspileProcessor.ts
index 1a301010..274b8329 100644
--- a/src/bscPlugin/transpile/BrsFilePreTranspileProcessor.ts
+++ b/src/bscPlugin/transpile/BrsFilePreTranspileProcessor.ts
@@ -1,6 +1,12 @@
-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 {
@@ -10,32 +16,93 @@ export class BrsFilePreTranspileProcessor {
     }
 
     public process() {
-        this.replaceEnumValues();
+        if (isBrsFile(this.event.file)) {
+            this.iterateExpressions();
+        }
+    }
+
+    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);
+            }
+        }
     }
 
-    private replaceEnumValues() {
-        const membersByEnum = new Cache<string, Map<string, string>>();
+    /**
+     * 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);
 
-        const enumLookup = this.event.file.program.getFirstScopeForFile(this.event.file)?.getEnumMap();
-        //skip this logic if current scope has no enums
-        if ((enumLookup?.size ?? 0) === 0) {
-            return;
+        if (result) {
+            return {
+                enum: result.item
+            };
+        }
+        //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
+                ))
+            };
         }
-        for (const expression of this.event.file.parser.references.expressions) {
-            const parts = util.getAllDottedGetParts(expression)?.map(x => x.text.toLowerCase());
-            if (parts) {
-                //get the name of the enum member
-                const memberName = parts.pop();
-                //get the name of the enum (including leading namespace if applicable)
-                const enumName = parts.join('.');
-                const lowerEnumName = enumName.toLowerCase();
-                const theEnum = enumLookup.get(lowerEnumName)?.item;
-                if (theEnum) {
-                    const members = membersByEnum.getOrAdd(lowerEnumName, () => theEnum.getMemberValueMap());
-                    const value = members?.get(memberName);
-                    this.event.editor.overrideTranspileResult(expression, value);
+    }
+
+    private processExpression(expression: Expression, scope: Scope) {
+        let containingNamespace = this.event.file.getNamespaceStatementForPosition(expression.range.start)?.getName(ParseMode.BrighterScript);
+
+        const parts = util.splitExpression(expression);
+
+        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;
+            }
+
+            let value: Expression;
+
+            //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/BrsFileValidator.ts b/src/bscPlugin/validation/BrsFileValidator.ts
index d83a9b27..d26e4322 100644
--- a/src/bscPlugin/validation/BrsFileValidator.ts
+++ b/src/bscPlugin/validation/BrsFileValidator.ts
@@ -1,4 +1,4 @@
-import { isClassStatement, isCommentStatement, isEnumStatement, isFunctionStatement, isImportStatement, isInterfaceStatement, isLibraryStatement, isLiteralExpression, isNamespaceStatement } from '../../astUtils/reflection';
+import { isClassStatement, isCommentStatement, isConstStatement, isEnumStatement, isFunctionStatement, isImportStatement, isInterfaceStatement, isLibraryStatement, isLiteralExpression, isNamespaceStatement } from '../../astUtils/reflection';
 import { DiagnosticMessages } from '../../DiagnosticMessages';
 import type { BrsFile } from '../../files/BrsFile';
 import type { BsDiagnostic, OnFileValidateEvent } from '../../interfaces';
@@ -113,7 +113,8 @@ export class BrsFileValidator {
                     !isInterfaceStatement(statement) &&
                     !isCommentStatement(statement) &&
                     !isLibraryStatement(statement) &&
-                    !isImportStatement(statement)
+                    !isImportStatement(statement) &&
+                    !isConstStatement(statement)
                 ) {
                     this.event.file.addDiagnostic({
                         ...DiagnosticMessages.unexpectedStatementOutsideFunction(),
diff --git a/src/bscPlugin/validation/ScopeValidator.ts b/src/bscPlugin/validation/ScopeValidator.ts
index f8e36d46..f17c39ed 100644
--- a/src/bscPlugin/validation/ScopeValidator.ts
+++ b/src/bscPlugin/validation/ScopeValidator.ts
@@ -158,14 +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/files/BrsFile.ts b/src/files/BrsFile.ts
index 48a6263d..cc1b77a9 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, isEnumStatement } from '../astUtils/reflection';
+import { isCallExpression, isClassMethodStatement, isClassStatement, isCommentStatement, isDottedGetExpression, isFunctionExpression, isFunctionStatement, isFunctionType, isLibraryStatement, isLiteralExpression, isNamespaceStatement, isStringType, isVariableExpression, isXmlFile, isImportStatement, isClassFieldStatement, isEnumStatement, isConstStatement } from '../astUtils/reflection';
 import type { BscType } from '../types/BscType';
 import { createVisitor, WalkMode } from '../astUtils/visitors';
 import type { DependencyGraph } from '../DependencyGraph';
@@ -869,17 +869,18 @@ export class BrsFile {
                 result.push(...scope.getPropertyNameCompletions());
             }
         } else {
-            //include namespaces
-            result.push(...namespaceCompletions);
-
-            //include class names
-            result.push(...classNameCompletions);
-
-            //include enums
-            result.push(...this.getNonNamespacedEnumStatementCompletions(currentToken, this.parseMode, scope));
-
-            //include the global callables
-            result.push(...scope.getCallablesAsCompletions(this.parseMode));
+            result.push(
+                //include namespaces
+                ...namespaceCompletions,
+                //include class names
+                ...classNameCompletions,
+                //include enums
+                ...this.getNonNamespacedEnumStatementCompletions(currentToken, this.parseMode, scope),
+                //include constants
+                ...this.getNonNamespacedConstStatementCompletions(currentToken, this.parseMode, scope),
+                //include the global callables
+                ...scope.getCallablesAsCompletions(this.parseMode)
+            );
 
             //add `m` because that's always valid within a function
             result.push({
@@ -1008,6 +1009,27 @@ export class BrsFile {
         return [...results.values()];
     }
 
+    private getNonNamespacedConstStatementCompletions(currentToken: Token, parseMode: ParseMode, scope: Scope): CompletionItem[] {
+        if (parseMode !== ParseMode.BrighterScript) {
+            return [];
+        }
+        const containingNamespaceName = this.getNamespaceStatementForPosition(currentToken?.range?.start)?.name + '.';
+        const results = new Map<string, CompletionItem>();
+        const map = scope.getConstMap();
+        for (const key of [...map.keys()]) {
+            const statement = map.get(key).item;
+            const fullName = statement.fullName;
+            //if the item is contained within our own namespace, or if it's non-namespaced
+            if (fullName.startsWith(containingNamespaceName) || !fullName.includes('.')) {
+                results.set(fullName, {
+                    label: statement.name,
+                    kind: CompletionItemKind.Constant
+                });
+            }
+        }
+        return [...results.values()];
+    }
+
     private getEnumMemberStatementCompletions(currentToken: Token, parseMode: ParseMode, scope: Scope): CompletionItem[] {
         if (parseMode === ParseMode.BrightScript || !currentToken) {
             return [];
@@ -1090,6 +1112,11 @@ export class BrsFile {
                             label: stmt.name,
                             kind: CompletionItemKind.Enum
                         });
+                    } else if (isConstStatement(stmt) && !newToken) {
+                        result.set(stmt.name, {
+                            label: stmt.name,
+                            kind: CompletionItemKind.Constant
+                        });
                     }
                 }
             }
diff --git a/src/lexer/Lexer.spec.ts b/src/lexer/Lexer.spec.ts
index d2b3a438..7e7c2686 100644
--- a/src/lexer/Lexer.spec.ts
+++ b/src/lexer/Lexer.spec.ts
@@ -9,6 +9,13 @@ import { Range } from 'vscode-languageserver';
 import util from '../util';
 
 describe('lexer', () => {
+    it('recognizes the `const` keyword', () => {
+        let { tokens } = Lexer.scan('const');
+        expect(tokens.map(x => x.kind)).to.eql([
+            TokenKind.Const,
+            TokenKind.Eof
+        ]);
+    });
     it('recognizes namespace keywords', () => {
         let { tokens } = Lexer.scan('namespace end namespace endnamespace end   namespace');
         expect(tokens.map(x => x.kind)).to.eql([
diff --git a/src/lexer/TokenKind.ts b/src/lexer/TokenKind.ts
index 73a0f4eb..42e98b3c 100644
--- a/src/lexer/TokenKind.ts
+++ b/src/lexer/TokenKind.ts
@@ -160,6 +160,7 @@ export enum TokenKind {
     Override = 'Override',
     Import = 'Import',
     EndInterface = 'EndInterface',
+    Const = 'Const',
 
     //brighterscript source literals
     LineNumLiteral = 'LineNumLiteral',
@@ -316,7 +317,8 @@ export const Keywords: Record<string, TokenKind> = {
     'end try': TokenKind.EndTry,
     throw: TokenKind.Throw,
     'end interface': TokenKind.EndInterface,
-    endinterface: TokenKind.EndInterface
+    endinterface: TokenKind.EndInterface,
+    const: TokenKind.Const
 };
 //hide the constructor prototype method because it causes issues
 Keywords.constructor = undefined;
@@ -437,7 +439,8 @@ export const AllowedProperties = [
     TokenKind.Catch,
     TokenKind.EndTry,
     TokenKind.Throw,
-    TokenKind.EndInterface
+    TokenKind.EndInterface,
+    TokenKind.Const
 ];
 
 /** List of TokenKind that are allowed as local var identifiers. */
@@ -469,7 +472,8 @@ export const AllowedLocalIdentifiers = [
     TokenKind.Import,
     TokenKind.Try,
     TokenKind.Catch,
-    TokenKind.EndTry
+    TokenKind.EndTry,
+    TokenKind.Const
 ];
 
 export const BrighterScriptSourceLiterals = [
diff --git a/src/parser/Parser.Class.spec.ts b/src/parser/Parser.Class.spec.ts
index e8264ab7..0197cba8 100644
--- a/src/parser/Parser.Class.spec.ts
+++ b/src/parser/Parser.Class.spec.ts
@@ -301,7 +301,7 @@ describe('parser class', () => {
             expect(cls.methods[0].accessModifier).to.be.undefined;
         });
 
-        it('supports primative field initializers', () => {
+        it('supports primitive field initializers', () => {
             let { tokens } = Lexer.scan(`
                 class Person
                     name = "Bob"
diff --git a/src/parser/Parser.ts b/src/parser/Parser.ts
index 1c890c04..576c4df8 100644
--- a/src/parser/Parser.ts
+++ b/src/parser/Parser.ts
@@ -17,6 +17,9 @@ import type {
     PrintSeparatorTab,
     Statement
 } from './Statement';
+import {
+    ConstStatement
+} from './Statement';
 import {
     AssignmentStatement,
     Block,
@@ -220,13 +223,7 @@ export class Parser {
      * Static wrapper around creating a new parser and parsing a list of tokens
      */
     public static parse(toParse: Token[] | string, options?: ParseOptions): Parser {
-        let tokens: Token[];
-        if (typeof toParse === 'string') {
-            tokens = Lexer.scan(toParse).tokens;
-        } else {
-            tokens = toParse;
-        }
-        return new Parser().parse(tokens, options);
+        return new Parser().parse(toParse, options);
     }
 
     /**
@@ -234,7 +231,13 @@ export class Parser {
      * @param toParse the array of tokens to parse. May not contain any whitespace tokens
      * @returns the same instance of the parser which contains the diagnostics and statements
      */
-    public parse(tokens: Token[], options?: ParseOptions) {
+    public parse(toParse: Token[] | string, options?: ParseOptions) {
+        let tokens: Token[];
+        if (typeof toParse === 'string') {
+            tokens = Lexer.scan(toParse).tokens;
+        } else {
+            tokens = toParse;
+        }
         this.logger = options?.logger ?? new Logger();
         this.tokens = tokens;
         this.options = this.sanitizeParseOptions(options);
@@ -337,6 +340,10 @@ export class Parser {
                 return this.libraryStatement();
             }
 
+            if (this.check(TokenKind.Const)) {
+                return this.constDeclaration();
+            }
+
             if (this.check(TokenKind.At) && this.checkNext(TokenKind.Identifier)) {
                 return this.annotationExpression();
             }
@@ -1461,6 +1468,23 @@ export class Parser {
         return result;
     }
 
+    private constDeclaration(): ConstStatement | undefined {
+        const constToken = this.advance();
+        const nameToken = this.identifier();
+        const equalToken = this.consumeToken(TokenKind.Equal);
+        const expression = this.expression();
+        const statement = new ConstStatement({
+            const: constToken,
+            name: nameToken,
+            equals: equalToken
+        }, expression, this.currentNamespaceName);
+        if (nameToken) {
+            this.currentSymbolTable.addSymbol(nameToken.text, nameToken.range, DynamicType.instance);
+        }
+        this._references.constStatements.push(statement);
+        return statement;
+    }
+
     private libraryStatement(): LibraryStatement | undefined {
         let libStatement = new LibraryStatement({
             library: this.advance(),
@@ -3117,6 +3141,9 @@ export class Parser {
             EnumStatement: e => {
                 this._references.enumStatements.push(e);
             },
+            ConstStatement: s => {
+                this._references.constStatements.push(s);
+            },
             UnaryExpression: e => {
                 this._references.expressions.add(e);
             },
@@ -3205,6 +3232,18 @@ export class References {
         });
     }
 
+    public constStatements = [] as ConstStatement[];
+
+    public get constStatementLookup() {
+        return this.cache.getOrAdd('consts', () => {
+            const result = new Map<string, ConstStatement>();
+            for (const stmt of this.constStatements) {
+                result.set(stmt.fullName.toLowerCase(), stmt);
+            }
+            return result;
+        });
+    }
+
     /**
      * A collection of full expressions. This excludes intermediary expressions.
      *
diff --git a/src/parser/Statement.ts b/src/parser/Statement.ts
index da501a05..4c13a85d 100644
--- a/src/parser/Statement.ts
+++ b/src/parser/Statement.ts
@@ -2457,3 +2457,66 @@ export class EnumMemberStatement extends Statement implements TypedefProvider {
         }
     }
 }
+
+export class ConstStatement extends Statement implements TypedefProvider {
+
+    public constructor(
+        public tokens: {
+            const: Token;
+            name: Identifier;
+            equals: Token;
+        },
+        public value: Expression,
+        readonly namespaceName?: NamespacedVariableNameExpression
+    ) {
+        super();
+        this.range = util.createBoundingRange(this.tokens.const, this.tokens.name, this.tokens.equals, this.value);
+    }
+
+    public range: Range;
+
+    public get name() {
+        return this.tokens.name.text;
+    }
+
+    /**
+     * The name of the statement WITH its leading namespace (if applicable)
+     */
+    public get fullName() {
+        const name = this.tokens.name?.text;
+        if (name) {
+            if (this.namespaceName) {
+                let namespaceName = this.namespaceName.getName(ParseMode.BrighterScript);
+                return `${namespaceName}.${name}`;
+            } else {
+                return name;
+            }
+        } else {
+            //return undefined which will allow outside callers to know that this doesn't have a name
+            return undefined;
+        }
+    }
+
+    public transpile(state: BrsTranspileState): TranspileResult {
+        //const declarations don't exist at runtime, so just transpile empty
+        return [];
+    }
+
+    getTypedef(state: BrsTranspileState): (string | SourceNode)[] {
+        return [
+            state.tokenToSourceNode(this.tokens.const),
+            ' ',
+            state.tokenToSourceNode(this.tokens.name),
+            ' ',
+            state.tokenToSourceNode(this.tokens.equals),
+            ' ',
+            ...this.value.transpile(state)
+        ];
+    }
+
+    walk(visitor: WalkVisitor, options: WalkOptions) {
+        if (this.value && options.walkMode & InternalWalkMode.walkExpressions) {
+            walk(this, 'value', visitor, options);
+        }
+    }
+}
diff --git a/src/util.ts b/src/util.ts
index 897414c0..a13207e1 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
      */


Test patch:
diff --git a/src/parser/tests/statement/ConstStatement.spec.ts b/src/parser/tests/statement/ConstStatement.spec.ts
new file mode 100644
index 00000000..d810be16
--- /dev/null
+++ b/src/parser/tests/statement/ConstStatement.spec.ts
@@ -0,0 +1,199 @@
+import { expectCompletionsIncludes, expectZeroDiagnostics, getTestGetTypedef, getTestTranspile } from '../../../testHelpers.spec';
+import util, { standardizePath as s } from '../../../util';
+import { Program } from '../../../Program';
+import { createSandbox } from 'sinon';
+import { Parser } from '../../Parser';
+import { expect } from 'chai';
+import type { ConstStatement } from '../../Statement';
+import { TokenKind } from '../../../lexer/TokenKind';
+import { LiteralExpression } from '../../Expression';
+import { CompletionItemKind } from 'vscode-languageserver-protocol';
+
+const sinon = createSandbox();
+
+describe('ConstStatement', () => {
+    let rootDir = s`${process.cwd()}/.tmp/rootDir`;
+    let program: Program;
+    let parser: Parser;
+    let testTranspile = getTestTranspile(() => [program, rootDir]);
+    let testGetTypedef = getTestGetTypedef(() => [program, rootDir]);
+
+    beforeEach(() => {
+        program = new Program({ rootDir: rootDir, sourceMap: true });
+        parser = new Parser();
+    });
+    afterEach(() => {
+        sinon.restore();
+        program.dispose();
+    });
+
+    it('supports basic structure', () => {
+        parser.parse('const API_KEY = "abc"');
+        expectZeroDiagnostics(parser);
+        const statement = parser.ast.statements[0] as ConstStatement;
+        expect(statement.tokens.const?.kind).to.eql(TokenKind.Const);
+        expect(statement.tokens.name).to.include({
+            kind: TokenKind.Identifier,
+            text: 'API_KEY'
+        });
+        const value = statement.value as LiteralExpression;
+        expect(value).to.be.instanceof(LiteralExpression);
+        expect(value.token?.text).to.eql('"abc"');
+        //ensure range is correct
+        expect(statement.range).to.eql(util.createRange(0, 0, 0, 21));
+    });
+
+    it('produces typedef', () => {
+        testGetTypedef(`
+            const API_KEY = "abc"
+            const SOME_OBJ = {}
+            const SOME_ARR = []
+        `);
+    });
+
+    describe('transpile', () => {
+
+        it('transpiles simple consts', () => {
+            testTranspile(`
+                const API_KEY = "abc"
+                sub main()
+                    print API_KEY
+                end sub
+            `, `
+                sub main()
+                    print "abc"
+                end sub
+            `);
+        });
+
+        it('transpiles arrays', () => {
+            testTranspile(`
+                const WORDS = [
+                    "alpha"
+                    "beta"
+                ]
+                sub main()
+                    print WORDS
+                end sub
+            `, `
+                sub main()
+                    print ([
+                        "alpha"
+                        "beta"
+                    ])
+                end sub
+            `);
+        });
+
+        it('transpiles objects', () => {
+            testTranspile(`
+                const DEFAULTS = {
+                    alpha: true
+                    beta: true
+                }
+                sub main()
+                    print DEFAULTS
+                end sub
+            `, `
+                sub main()
+                    print ({
+                        alpha: true
+                        beta: true
+                    })
+                end sub
+            `);
+        });
+
+        it('supports consts inside namespaces', () => {
+            testTranspile(`
+                namespace network
+                    const API_KEY = "abc"
+                    sub get()
+                        print API_KEY
+                    end sub
+                end namespace
+                sub main()
+                    print network.API_KEY
+                end sub
+            `, `
+                sub network_get()
+                    print "abc"
+                end sub
+
+                sub main()
+                    print "abc"
+                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', () => {
+        it('shows up in standard completions', () => {
+            program.setFile('source/main.bs', `
+                const API_KEY = "123"
+                sub log(message)
+                    log()
+                end sub
+            `);
+
+            expectCompletionsIncludes(
+                // log(|)
+                program.getCompletions('source/main.bs', util.createPosition(3, 24)),
+                [{
+                    label: 'API_KEY',
+                    kind: CompletionItemKind.Constant
+                }]
+            );
+        });
+
+        it('shows up in namespace completions', () => {
+            program.setFile('source/main.bs', `
+                namespace constants
+                    const API_KEY = "123"
+                end namespace
+                sub log(message)
+                    log(constants.)
+                end sub
+            `);
+
+            expectCompletionsIncludes(
+                // log(|)
+                program.getCompletions('source/main.bs', util.createPosition(5, 34)),
+                [{
+                    label: 'API_KEY',
+                    kind: CompletionItemKind.Constant
+                }]
+            );
+        });
+    });
+});
diff --git a/src/parser/tests/statement/Enum.spec.ts b/src/parser/tests/statement/Enum.spec.ts
index d7d7a537..cd623558 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
+            `);
+        });
+    });
 });