Repository: rokucommunity/brighterscript
Pull request #631 (2022-06-15 20:01:56)

Flag usage of undefined variables
This PR finds usage of all variables that have not been defined. 

This is overlapping functionality with bslint's `LINT1001` rule, but we need this to do additional logic, and it's something a compiler should do natively anyway. LINT1001 is slightly superior in that it also detects "used before defined" errors, but they are not flagged that way in bslint and are included in the same LINT1001 bucket.

This adds basic symbol table support as well, just for name tracking purposes. 


Patch:
diff --git a/src/DiagnosticMessages.ts b/src/DiagnosticMessages.ts
index f007ca36..b9d87b22 100644
--- a/src/DiagnosticMessages.ts
+++ b/src/DiagnosticMessages.ts
@@ -13,11 +13,11 @@ export let DiagnosticMessages = {
         code: 1000,
         severity: DiagnosticSeverity.Error
     }),
-    callToUnknownFunction: (name: string, scopeName: string) => ({
-        message: `Cannot find function with name '${name}' when this file is included in scope '${scopeName}'`,
+    cannotFindName: (name: string) => ({
+        message: `Cannot find name '${name}'`,
         code: 1001,
         data: {
-            functionName: name
+            name: name
         },
         severity: DiagnosticSeverity.Error
     }),
diff --git a/src/LanguageServer.ts b/src/LanguageServer.ts
index dc9940ef..cb67a9a8 100644
--- a/src/LanguageServer.ts
+++ b/src/LanguageServer.ts
@@ -694,7 +694,7 @@ export class LanguageServer {
         //clone the diagnostics for each code action, since certain diagnostics can have circular reference properties that kill the language server if serialized
         for (const codeAction of codeActions) {
             if (codeAction.diagnostics) {
-                codeAction.diagnostics = codeAction.diagnostics.map(x => util.toDiagnostic(x));
+                codeAction.diagnostics = codeAction.diagnostics.map(x => util.toDiagnostic(x, params.textDocument.uri));
             }
         }
         return codeActions;
@@ -1213,10 +1213,11 @@ export class LanguageServer {
             const patch = this.diagnosticCollection.getPatch(this.projects);
 
             for (let filePath in patch) {
-                const diagnostics = patch[filePath].map(d => util.toDiagnostic(d));
+                const uri = URI.file(filePath).toString();
+                const diagnostics = patch[filePath].map(d => util.toDiagnostic(d, uri));
 
                 this.connection.sendDiagnostics({
-                    uri: URI.file(filePath).toString(),
+                    uri: uri,
                     diagnostics: diagnostics
                 });
             }
diff --git a/src/Program.spec.ts b/src/Program.spec.ts
index 8cf3a362..682a91ed 100644
--- a/src/Program.spec.ts
+++ b/src/Program.spec.ts
@@ -494,7 +494,7 @@ describe('Program', () => {
 
             program.validate();
             expectDiagnostics(program, [
-                DiagnosticMessages.callToUnknownFunction('DoSomething', 'source')
+                DiagnosticMessages.cannotFindName('DoSomething')
             ]);
         });
 
@@ -1360,7 +1360,7 @@ describe('Program', () => {
 
             //there should be an error when calling DoParentThing, since it doesn't exist on child or parent
             expectDiagnostics(program, [
-                DiagnosticMessages.callToUnknownFunction('DoParentThing', '').code
+                DiagnosticMessages.cannotFindName('DoParentThing')
             ]);
 
             //add the script into the parent
@@ -1519,7 +1519,7 @@ describe('Program', () => {
             ];
 
             expectDiagnostics(program, [
-                DiagnosticMessages.callToUnknownFunction('C', 'source')
+                DiagnosticMessages.cannotFindName('C')
             ]);
         });
     });
@@ -2186,7 +2186,8 @@ describe('Program', () => {
         it('gets signature help for callfunc method', () => {
             program.setFile('source/main.bs', `
                 function main()
-                    myNode@.sayHello(arg1)
+                    myNode = createObject("roSGNode", "Component1")
+                    myNode@.sayHello(1)
                 end function
             `);
             program.setFile('components/MyNode.bs', `
@@ -2203,7 +2204,7 @@ describe('Program', () => {
                 </component>`);
             program.validate();
 
-            let signatureHelp = (program.getSignatureHelp(`${rootDir}/source/main.bs`, Position.create(2, 36)));
+            let signatureHelp = (program.getSignatureHelp(`${rootDir}/source/main.bs`, Position.create(3, 36)));
             expectZeroDiagnostics(program);
             expect(signatureHelp[0].signature.label).to.equal('function sayHello(text, text2)');
         });
@@ -2211,7 +2212,8 @@ describe('Program', () => {
         it('does not get signature help for callfunc method, referenced by dot', () => {
             program.setFile('source/main.bs', `
                 function main()
-                    myNode.sayHello(arg1)
+                    myNode = createObject("roSGNode", "Component1")
+                    myNode.sayHello(1, 2)
                 end function
             `);
             program.setFile('components/MyNode.bs', `
@@ -2228,7 +2230,7 @@ describe('Program', () => {
                 </component>`);
             program.validate();
 
-            let signatureHelp = (program.getSignatureHelp(`${rootDir}/source/main.bs`, Position.create(2, 36)));
+            let signatureHelp = (program.getSignatureHelp(`${rootDir}/source/main.bs`, Position.create(3, 36)));
             expectZeroDiagnostics(program);
             //note - callfunc completions and signatures are not yet correctly identifying methods that are exposed in an interace - waiting on the new xml branch for that
             expect(signatureHelp).to.be.empty;
@@ -2532,4 +2534,26 @@ describe('Program', () => {
             expect(plugin.afterFileValidate.callCount).to.equal(1);
         });
     });
+
+    describe('getScopesForFile', () => {
+        it('returns empty array when no scopes were found', () => {
+            expect(program.getScopesForFile('does/not/exist')).to.eql([]);
+        });
+    });
+
+    describe('findFilesForEnum', () => {
+        it('finds files', () => {
+            const file = program.setFile('source/main.bs', `
+                enum Direction
+                    up
+                    down
+                end enum
+            `);
+            expect(
+                program.findFilesForEnum('Direction').map(x => x.srcPath)
+            ).to.eql([
+                file.srcPath
+            ]);
+        });
+    });
 });
diff --git a/src/Program.ts b/src/Program.ts
index f087dd2c..4df9a9b3 100644
--- a/src/Program.ts
+++ b/src/Program.ts
@@ -736,13 +736,18 @@ export class Program {
      * Get a list of all scopes the file is loaded into
      * @param file
      */
-    public getScopesForFile(file: XmlFile | BrsFile) {
+    public getScopesForFile(file: XmlFile | BrsFile | string) {
+        if (typeof file === 'string') {
+            file = this.getFile(file);
+        }
         let result = [] as Scope[];
-        for (let key in this.scopes) {
-            let scope = this.scopes[key];
+        if (file) {
+            for (let key in this.scopes) {
+                let scope = this.scopes[key];
 
-            if (scope.hasFile(file)) {
-                result.push(scope);
+                if (scope.hasFile(file)) {
+                    result.push(scope);
+                }
             }
         }
         return result;
@@ -1484,6 +1489,34 @@ export class Program {
         return files;
     }
 
+    public findFilesForNamespace(name: string) {
+        const files = [] as BscFile[];
+        const lowerName = name.toLowerCase();
+        //find every file with this class defined
+        for (const file of Object.values(this.files)) {
+            if (isBrsFile(file)) {
+                if (file.parser.references.namespaceStatements.find(x => x.name.toLowerCase() === lowerName)) {
+                    files.push(file);
+                }
+            }
+        }
+        return files;
+    }
+
+    public findFilesForEnum(name: string) {
+        const files = [] as BscFile[];
+        const lowerName = name.toLowerCase();
+        //find every file with this class defined
+        for (const file of Object.values(this.files)) {
+            if (isBrsFile(file)) {
+                if (file.parser.references.enumStatementLookup.get(lowerName)) {
+                    files.push(file);
+                }
+            }
+        }
+        return files;
+    }
+
     /**
      * Get a map of the manifest information
      */
diff --git a/src/Scope.spec.ts b/src/Scope.spec.ts
index f2fd8cf4..45d57f71 100644
--- a/src/Scope.spec.ts
+++ b/src/Scope.spec.ts
@@ -1,7 +1,7 @@
 import { expect } from 'chai';
 import * as sinonImport from 'sinon';
 import { Position, Range } from 'vscode-languageserver';
-import { standardizePath as s } from './util';
+import util, { standardizePath as s } from './util';
 import { DiagnosticMessages } from './DiagnosticMessages';
 import { Program } from './Program';
 import { ParseMode } from './parser/Parser';
@@ -42,6 +42,44 @@ describe('Scope', () => {
         expectZeroDiagnostics(program);
     });
 
+    it('builds symbol table with namespace-relative entries', () => {
+        const file = program.setFile<BrsFile>('source/alpha.bs', `
+            namespace alpha
+                class Beta
+                end class
+            end namespace
+            namespace alpha
+                class Charlie extends Beta
+                end class
+                function createBeta()
+                    return new Beta()
+                end function
+            end namespace
+        `);
+        program.setFile('source/main.bs', `
+            function main()
+                alpha.createBeta()
+                thing = new alpha.Beta()
+            end function
+        `);
+        program.validate();
+        const scope = program.getScopesForFile('source/alpha.bs')[0];
+        scope.linkSymbolTable();
+        const symbolTable = file.parser.references.namespaceStatements[1].symbolTable;
+        //the symbol table should contain the relative names for all items in this namespace across files
+        expect(
+            symbolTable.hasSymbol('Beta')
+        ).to.be.true;
+        expect(
+            symbolTable.hasSymbol('Charlie')
+        ).to.be.true;
+        expect(
+            symbolTable.hasSymbol('createBeta')
+        ).to.be.true;
+
+        expectZeroDiagnostics(program);
+    });
+
     it('handles variables with javascript prototype names', () => {
         program.setFile('source/main.brs', `
             sub main()
@@ -160,6 +198,109 @@ describe('Scope', () => {
     });
 
     describe('validate', () => {
+        it('detects unknown namespace names', () => {
+            program.setFile('source/main.bs', `
+                sub main()
+                    Name1.thing()
+                    Name2.thing()
+                end sub
+                namespace Name1
+                    sub thing()
+                    end sub
+                end namespace
+            `);
+            program.validate();
+            expectDiagnostics(program, [
+                DiagnosticMessages.cannotFindName('Name2')
+            ]);
+        });
+
+        it('detects unknown namespace sub-names', () => {
+            program.setFile('source/main.bs', `
+                sub main()
+                    Name1.subname.thing()
+                end sub
+                namespace Name1
+                    sub thing()
+                    end sub
+                end namespace
+            `);
+            program.validate();
+            expectDiagnostics(program, [{
+                ...DiagnosticMessages.cannotFindName('subname'),
+                range: util.createRange(2, 26, 2, 33)
+            }]);
+        });
+
+        it('detects unknown enum names', () => {
+            program.setFile('source/main.bs', `
+                sub main()
+                    print Direction.up
+                    print up.Direction
+                end sub
+                enum Direction
+                    up
+                end enum
+            `);
+            program.validate();
+            expectDiagnostics(program, [
+                DiagnosticMessages.cannotFindName('up')
+            ]);
+        });
+
+        it('detects unknown function names', () => {
+            program.setFile('source/main.bs', `
+                sub main()
+                    print go.toStr()
+                    print go2.toStr()
+                end sub
+
+                function go()
+                end function
+            `);
+            program.validate();
+            expectDiagnostics(program, [
+                DiagnosticMessages.cannotFindName('go2')
+            ]);
+        });
+
+        it('detects unknown local var names', () => {
+            program.setFile('source/lib.bs', `
+                sub libFunc(param1)
+                    print param1
+                    print param2
+                    name1 = "bob"
+                    print name1
+                    print name2
+                    for each item1 in param1
+                        print item1
+                        print item2
+                    end for
+                    for idx1 = 0 to 10
+                        print idx1
+                        print idx2
+                    end for
+                    try
+                        print 1
+                    catch ex1
+                        print ex1
+                        print ex2
+                    end try
+                end sub
+
+                function go()
+                end function
+            `);
+            program.validate();
+            expectDiagnostics(program, [
+                DiagnosticMessages.cannotFindName('param2'),
+                DiagnosticMessages.cannotFindName('name2'),
+                DiagnosticMessages.cannotFindName('item2'),
+                DiagnosticMessages.cannotFindName('idx2'),
+                DiagnosticMessages.cannotFindName('ex2')
+            ]);
+        });
+
         describe('createObject', () => {
             it('recognizes various scenegraph nodes', () => {
                 program.setFile(`source/file.brs`, `
@@ -463,7 +604,7 @@ describe('Scope', () => {
             });
 
             it('flags scope function with same name (but different case) as built-in function', () => {
-                program.setFile({ src: s`${rootDir}/source/main.brs`, dest: s`source/main.brs` }, `
+                program.setFile('source/main.brs', `
                     sub main()
                         print str(12345) ' prints 12345 (i.e. our str() function below is ignored)
                     end sub
@@ -509,7 +650,7 @@ describe('Scope', () => {
             //validate the scope
             program.validate();
             expectDiagnostics(program, [
-                DiagnosticMessages.callToUnknownFunction('DoB', 'source')
+                DiagnosticMessages.cannotFindName('DoB')
             ]);
         });
 
@@ -525,7 +666,7 @@ describe('Scope', () => {
             //validate the scope
             program.validate();
             expectDiagnostics(program, [
-                DiagnosticMessages.callToUnknownFunction('DoC', 'source')
+                DiagnosticMessages.cannotFindName('DoC')
             ]);
         });
 
diff --git a/src/Scope.ts b/src/Scope.ts
index 86b6a020..2ae549c6 100644
--- a/src/Scope.ts
+++ b/src/Scope.ts
@@ -18,6 +18,7 @@ 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 { SymbolTable } from './SymbolTable';
 
 /**
  * A class to keep track of all declarations within a given scope (like source scope, component scope)
@@ -476,9 +477,9 @@ export class Scope {
     public buildNamespaceLookup() {
         let namespaceLookup = new Map<string, NamespaceContainer>();
         this.enumerateBrsFiles((file) => {
-            for (let namespace of file.parser.references.namespaceStatements) {
+            for (let namespaceStatement of file.parser.references.namespaceStatements) {
                 //TODO should we handle non-brighterscript?
-                let name = namespace.nameExpression.getName(ParseMode.BrighterScript);
+                let name = namespaceStatement.nameExpression.getName(ParseMode.BrighterScript);
                 let nameParts = name.split('.');
 
                 let loopName = null;
@@ -491,19 +492,20 @@ export class Scope {
                         namespaceLookup.set(lowerLoopName, {
                             file: file,
                             fullName: loopName,
-                            nameRange: namespace.nameExpression.range,
+                            nameRange: namespaceStatement.nameExpression.range,
                             lastPartName: part,
                             namespaces: new Map<string, NamespaceContainer>(),
                             classStatements: {},
                             functionStatements: {},
+                            enumStatements: new Map<string, EnumStatement>(),
                             statements: [],
-                            enumStatements: new Map<string, EnumStatement>()
+                            symbolTable: new SymbolTable(this.symbolTable)
                         });
                     }
                 }
                 let ns = namespaceLookup.get(name.toLowerCase());
-                ns.statements.push(...namespace.body.statements);
-                for (let statement of namespace.body.statements) {
+                ns.statements.push(...namespaceStatement.body.statements);
+                for (let statement of namespaceStatement.body.statements) {
                     if (isClassStatement(statement) && statement.name) {
                         ns.classStatements[statement.name.text.toLowerCase()] = statement;
                     } else if (isFunctionStatement(statement) && statement.name) {
@@ -512,6 +514,9 @@ export class Scope {
                         ns.enumStatements.set(statement.fullName.toLowerCase(), statement);
                     }
                 }
+                // Merges all the symbol tables of the namespace statements into the new symbol table created above.
+                // Set those symbol tables to have this new merged table as a parent
+                ns.symbolTable.mergeSymbolTable(namespaceStatement.symbolTable);
             }
 
             //associate child namespaces with their parents
@@ -578,6 +583,8 @@ export class Scope {
             let callableContainerMap = util.getCallableContainersByLowerName(callables);
             let files = this.getOwnFiles();
 
+            //Since statements from files are shared across multiple scopes, we need to link those statements to the current scope
+            this.linkSymbolTable();
             this.program.plugins.emit('beforeScopeValidate', this, files, callableContainerMap);
 
             this.program.plugins.emit('onScopeValidate', {
@@ -587,6 +594,8 @@ export class Scope {
             this._validate(callableContainerMap);
 
             this.program.plugins.emit('afterScopeValidate', this, files, callableContainerMap);
+            //unlink all symbol tables from this scope (so they don't accidentally stick around)
+            this.unlinkSymbolTable();
 
             (this as any).isValidated = true;
         });
@@ -604,7 +613,6 @@ export class Scope {
 
         //do many per-file checks
         this.enumerateBrsFiles((file) => {
-            this.diagnosticDetectCallsToUnknownFunctions(file, callableContainerMap);
             this.diagnosticDetectFunctionCallsWithWrongParamCount(file, callableContainerMap);
             this.diagnosticDetectShadowedLocalVars(file, callableContainerMap);
             this.diagnosticDetectFunctionCollisions(file);
@@ -622,6 +630,50 @@ export class Scope {
         this.cache.clear();
     }
 
+    public get symbolTable() {
+        return this.cache.getOrAdd('symbolTable', () => {
+            const result = new SymbolTable(this.getParentScope()?.symbolTable);
+            for (let file of this.getOwnFiles()) {
+                if (isBrsFile(file)) {
+                    result.mergeSymbolTable(file.parser?.symbolTable);
+                }
+            }
+            return result;
+        });
+    }
+
+    /**
+    * Builds the current symbol table for the scope, by merging the tables for all the files in this scope.
+    * Also links all file symbols tables to this new table
+    * This will only rebuilt if the symbol table has not been built before
+    */
+    public linkSymbolTable() {
+        for (const file of this.getAllFiles()) {
+            if (isBrsFile(file)) {
+                file.parser.symbolTable.pushParent(this.symbolTable);
+
+                //link each NamespaceStatement's SymbolTable with the aggregate NamespaceLookup SymbolTable
+                for (const namespace of file.parser.references.namespaceStatements) {
+                    const namespaceNameLower = namespace.nameExpression.getName(ParseMode.BrighterScript).toLowerCase();
+                    const namespaceSymbolTable = this.namespaceLookup.get(namespaceNameLower).symbolTable;
+                    namespace.symbolTable.pushParent(namespaceSymbolTable);
+                }
+            }
+        }
+    }
+
+    public unlinkSymbolTable() {
+        for (let file of this.getOwnFiles()) {
+            if (isBrsFile(file)) {
+                file.parser?.symbolTable.popParent();
+
+                for (const namespace of file.parser.references.namespaceStatements) {
+                    namespace.symbolTable.popParent();
+                }
+            }
+        }
+    }
+
     private detectVariableNamespaceCollisions(file: BrsFile) {
         //find all function parameters
         for (let func of file.parser.references.functionExpressions) {
@@ -855,56 +907,6 @@ export class Scope {
         }
     }
 
-    /**
-     * Detect calls to functions that are not defined in this scope
-     * @param file
-     * @param callablesByLowerName
-     */
-    private diagnosticDetectCallsToUnknownFunctions(file: BscFile, callablesByLowerName: CallableContainerMap) {
-        //validate all expression calls
-        for (let expCall of file.functionCalls) {
-            const lowerName = expCall.name.toLowerCase();
-            //for now, skip validation on any method named "super" within `.bs` contexts.
-            //TODO revise this logic so we know if this function call resides within a class constructor function
-            if (file.extension === '.bs' && lowerName === 'super') {
-                continue;
-            }
-
-            //get the local scope for this expression
-            let scope = file.getFunctionScopeAtPosition(expCall.nameRange.start);
-
-            //if we don't already have a variable with this name.
-            if (!scope?.getVariableByName(lowerName)) {
-                let callablesWithThisName: CallableContainer[];
-
-                if (expCall.functionScope.func.namespaceName) {
-                    // prefer namespaced function
-                    const potentialNamespacedCallable = expCall.functionScope.func.namespaceName.getName(ParseMode.BrightScript).toLowerCase() + '_' + lowerName;
-                    callablesWithThisName = callablesByLowerName.get(potentialNamespacedCallable.toLowerCase());
-                }
-                if (!callablesWithThisName) {
-                    // just try it as is
-                    callablesWithThisName = callablesByLowerName.get(lowerName);
-                }
-
-                //use the first item from callablesByLowerName, because if there are more, that's a separate error
-                let knownCallable = callablesWithThisName ? callablesWithThisName[0] : undefined;
-
-                //detect calls to unknown functions
-                if (!knownCallable) {
-                    this.diagnostics.push({
-                        ...DiagnosticMessages.callToUnknownFunction(expCall.name, this.name),
-                        range: expCall.nameRange,
-                        file: file
-                    });
-                }
-            } else {
-                //if we found a variable with the same name as the function, assume the call is "known".
-                //If the variable is a different type, some other check should add a diagnostic for that.
-            }
-        }
-    }
-
     /**
      * Create diagnostics for any duplicate function declarations
      * @param callablesByLowerName
@@ -1156,6 +1158,7 @@ interface NamespaceContainer {
     functionStatements: Record<string, FunctionStatement>;
     enumStatements: Map<string, EnumStatement>;
     namespaces: Map<string, NamespaceContainer>;
+    symbolTable: SymbolTable;
 }
 
 interface AugmentedNewExpression extends NewExpression {
diff --git a/src/SymbolTable.spec.ts b/src/SymbolTable.spec.ts
new file mode 100644
index 00000000..96820668
--- /dev/null
+++ b/src/SymbolTable.spec.ts
@@ -0,0 +1,61 @@
+import { SymbolTable } from './SymbolTable';
+import { expect } from 'chai';
+import { StringType } from './types/StringType';
+import { IntegerType } from './types/IntegerType';
+
+
+describe('SymbolTable', () => {
+    it('is case insensitive', () => {
+        const st = new SymbolTable();
+        st.addSymbol('foo', null, new StringType());
+        expect(st.getSymbol('FOO').length).eq(1);
+        expect(st.getSymbol('FOO')[0].type.toString()).eq('string');
+    });
+
+    it('stores all previous symbols', () => {
+        const st = new SymbolTable();
+        st.addSymbol('foo', null, new StringType());
+        st.addSymbol('foo', null, new IntegerType());
+        expect(st.getSymbol('FOO').length).eq(2);
+    });
+
+
+    it('reads from parent symbol table if not found in current', () => {
+        const parent = new SymbolTable();
+        const st = new SymbolTable(parent);
+        parent.addSymbol('foo', null, new StringType());
+        expect(st.getSymbol('foo')[0].type.toString()).eq('string');
+    });
+
+    it('reads from current table if it exists', () => {
+        const parent = new SymbolTable();
+        const st = new SymbolTable(parent);
+        parent.addSymbol('foo', null, new StringType());
+        st.addSymbol('foo', null, new IntegerType());
+        expect(st.getSymbol('foo')[0].type.toString()).eq('integer');
+    });
+
+    it('correct checks if a symbol is in the table using hasSymbol', () => {
+        const parent = new SymbolTable();
+        const child = new SymbolTable(parent);
+        parent.addSymbol('foo', null, new StringType());
+        child.addSymbol('bar', null, new IntegerType());
+        expect(parent.hasSymbol('foo')).to.be.true;
+        expect(parent.hasSymbol('bar')).to.be.false;
+        expect(child.hasSymbol('foo')).to.be.true;
+        expect(child.hasSymbol('bar')).to.be.true;
+        expect(child.hasSymbol('buz')).to.be.false;
+    });
+
+    describe('mergeSymbolTable', () => {
+
+        it('adds each symbol to the table', () => {
+            const st = new SymbolTable();
+            st.addSymbol('foo', null, new StringType());
+            const otherTable = new SymbolTable();
+            otherTable.addSymbol('bar', null, new IntegerType());
+            otherTable.addSymbol('foo', null, new IntegerType());
+            st.mergeSymbolTable(otherTable);
+        });
+    });
+});
diff --git a/src/SymbolTable.ts b/src/SymbolTable.ts
new file mode 100644
index 00000000..190b0c65
--- /dev/null
+++ b/src/SymbolTable.ts
@@ -0,0 +1,119 @@
+import type { Range } from 'vscode-languageserver';
+import type { BscType } from './types/BscType';
+
+
+/**
+ * Stores the types associated with variables and functions in the Brighterscript code
+ * Can be part of a hierarchy, so lookups can reference parent scopes
+ */
+export class SymbolTable {
+    constructor(
+        parent?: SymbolTable | undefined
+    ) {
+        this.pushParent(parent);
+    }
+
+    /**
+     * The map of symbols declared directly in this SymbolTable (excludes parent SymbolTable).
+     * Indexed by lower symbol name
+     */
+    private symbolMap = new Map<string, BscSymbol[]>();
+
+    public get parent() {
+        return this.parentStack[0];
+    }
+
+    /**
+     * Sets the parent table for lookups. There can only be one parent at a time, but sometimes you
+     * want to temporarily change the parent, and then restore it later. This allows that.
+     *
+     * @param [parent]
+     */
+    private parentStack: SymbolTable[] = [];
+
+    public pushParent(parent?: SymbolTable) {
+        this.parentStack.unshift(parent);
+        return parent;
+    }
+
+    /**
+     * Remove the current parent, restoring the previous parent (if there was one)
+     */
+    public popParent() {
+        return this.parentStack.shift();
+    }
+
+    /**
+     * Checks if the symbol table contains the given symbol by name
+     * If the identifier is not in this table, it will check the parent
+     *
+     * @param name the name to lookup
+     * @param searchParent should we look to our parent if we don't have the symbol?
+     * @returns true if this symbol is in the symbol table
+     */
+    hasSymbol(name: string, searchParent = true): boolean {
+        const key = name.toLowerCase();
+        let result = this.symbolMap.has(key);
+        if (!result && searchParent) {
+            result = !!this.parent?.hasSymbol(key);
+        }
+        return result;
+    }
+
+    /**
+     * Gets the name/type pair for a given named variable or function name
+     * If the identifier is not in this table, it will check the parent
+     *
+     * @param  name the name to lookup
+     * @param searchParent should we look to our parent if we don't have the symbol?
+     * @returns An array of BscSymbols - one for each time this symbol had a type implicitly defined
+     */
+    getSymbol(name: string, searchParent = true): BscSymbol[] {
+        const key = name.toLowerCase();
+        let result = this.symbolMap.get(key);
+        if (!result && searchParent) {
+            result = this.parent?.getSymbol(key);
+        }
+        return result;
+    }
+
+    /**
+     * Adds a new symbol to the table
+     * @param name
+     * @param  type
+     */
+    addSymbol(name: string, range: Range, type: BscType) {
+        const key = name.toLowerCase();
+        if (!this.symbolMap.has(key)) {
+            this.symbolMap.set(key, []);
+        }
+        this.symbolMap.get(key).push({
+            name: name,
+            range: range,
+            type: type
+        });
+    }
+
+    /**
+     * Adds all the symbols from another table to this one
+     * It will overwrite any existing symbols in this table
+     * @param symbolTable
+     */
+    mergeSymbolTable(symbolTable: SymbolTable) {
+        for (let [, value] of symbolTable.symbolMap) {
+            for (const symbol of value) {
+                this.addSymbol(
+                    symbol.name,
+                    symbol.range,
+                    symbol.type
+                );
+            }
+        }
+    }
+}
+
+export interface BscSymbol {
+    name: string;
+    range: Range;
+    type: BscType;
+}
diff --git a/src/bscPlugin/codeActions/CodeActionsProcessor.ts b/src/bscPlugin/codeActions/CodeActionsProcessor.ts
index bf332284..d455c708 100644
--- a/src/bscPlugin/codeActions/CodeActionsProcessor.ts
+++ b/src/bscPlugin/codeActions/CodeActionsProcessor.ts
@@ -18,8 +18,8 @@ export class CodeActionsProcessor {
 
     public process() {
         for (const diagnostic of this.event.diagnostics) {
-            if (diagnostic.code === DiagnosticCodeMap.callToUnknownFunction) {
-                this.suggestFunctionImports(diagnostic as any);
+            if (diagnostic.code === DiagnosticCodeMap.cannotFindName) {
+                this.suggestCannotFindName(diagnostic as any);
             } else if (diagnostic.code === DiagnosticCodeMap.classCouldNotBeFound) {
                 this.suggestClassImports(diagnostic as any);
             } else if (diagnostic.code === DiagnosticCodeMap.xmlComponentMissingExtendsAttribute) {
@@ -64,16 +64,22 @@ export class CodeActionsProcessor {
         }
     }
 
-    private suggestFunctionImports(diagnostic: DiagnosticMessageType<'callToUnknownFunction'>) {
+    private suggestCannotFindName(diagnostic: DiagnosticMessageType<'cannotFindName'>) {
         //skip if not a BrighterScript file
         if ((diagnostic.file as BrsFile).parseMode !== ParseMode.BrighterScript) {
             return;
         }
-        const lowerFunctionName = diagnostic.data.functionName.toLowerCase();
+        const lowerName = diagnostic.data.name.toLowerCase();
+
         this.suggestImports(
             diagnostic,
-            lowerFunctionName,
-            this.event.file.program.findFilesForFunction(lowerFunctionName)
+            lowerName,
+            [
+                ...this.event.file.program.findFilesForFunction(lowerName),
+                ...this.event.file.program.findFilesForClass(lowerName),
+                ...this.event.file.program.findFilesForNamespace(lowerName),
+                ...this.event.file.program.findFilesForEnum(lowerName)
+            ]
         );
     }
 
diff --git a/src/bscPlugin/validation/ScopeValidator.ts b/src/bscPlugin/validation/ScopeValidator.ts
index 3b149443..f8e36d46 100644
--- a/src/bscPlugin/validation/ScopeValidator.ts
+++ b/src/bscPlugin/validation/ScopeValidator.ts
@@ -1,5 +1,5 @@
 import { URI } from 'vscode-uri';
-import { isBinaryExpression, isBrsFile, isLiteralExpression } from '../../astUtils/reflection';
+import { isBrsFile, isCallExpression, isLiteralExpression, isNewExpression, isXmlScope } from '../../astUtils/reflection';
 import { Cache } from '../../Cache';
 import { DiagnosticMessages } from '../../DiagnosticMessages';
 import type { BrsFile } from '../../files/BrsFile';
@@ -10,6 +10,9 @@ import util from '../../util';
 import { nodes, components } from '../../roku-types';
 import type { BRSComponentData } from '../../roku-types';
 import type { Token } from '../../lexer/Token';
+import type { Scope } from '../../Scope';
+import type { SymbolTable } from '../../SymbolTable';
+import type { DiagnosticRelatedInformation, Position } from 'vscode-languageserver';
 
 /**
  * The lower-case names of all platform-included scenegraph nodes
@@ -28,13 +31,16 @@ export class ScopeValidator {
 
     public processEvent(event: OnScopeValidateEvent) {
         this.events.push(event);
-        this.validateEnumUsage(event);
+        event.scope.linkSymbolTable();
         this.detectDuplicateEnums(event);
         this.validateCreateObjectCalls(event);
+        this.iterateExpressions(event);
+        event.scope.unlinkSymbolTable();
     }
 
     public reset() {
-        this.cache.clear();
+        this.onceCache.clear();
+        this.multiScopeCache.clear();
         this.events = [];
     }
 
@@ -43,76 +49,175 @@ export class ScopeValidator {
      * for diagnostics where scope isn't important. (i.e. CreateObject validations)
      */
     private addDiagnosticOnce(event: OnScopeValidateEvent, diagnostic: BsDiagnostic) {
-        const key = `${diagnostic.code}-${diagnostic.message}-${util.rangeToString(diagnostic.range)}`;
-        if (!this.cache.has(key)) {
-            this.cache.set(key, true);
+        this.onceCache.getOrAdd(`${diagnostic.code}-${diagnostic.message}-${util.rangeToString(diagnostic.range)}`, () => {
             event.scope.addDiagnostics([diagnostic]);
-        }
+            return true;
+        });
     }
+    private onceCache = new Cache<string, boolean>();
 
-    private cache = new Map<string, boolean>();
+    private addDiagnostic(event: OnScopeValidateEvent, diagnostic: BsDiagnostic) {
+        event.scope.addDiagnostics([diagnostic]);
+    }
 
     /**
-     * Find all expressions and validate the ones that look like enums
+     * Add a diagnostic (to the first scope) that will have `relatedInformation` for each affected scope
      */
-    public validateEnumUsage(event: OnScopeValidateEvent) {
-        const diagnostics = [] as BsDiagnostic[];
-
-        //if there are any enums defined in this scope
-        const enumLookup = event.scope.getEnumMap();
+    private addMultiScopeDiagnostic(event: OnScopeValidateEvent, diagnostic: BsDiagnostic, message = 'Not defined in scope') {
+        diagnostic = this.multiScopeCache.getOrAdd(`${diagnostic.code}-${diagnostic.message}-${util.rangeToString(diagnostic.range)}`, () => {
+            if (!diagnostic.relatedInformation) {
+                diagnostic.relatedInformation = [];
+            }
+            this.addDiagnostic(event, diagnostic);
+            return diagnostic;
+        });
+        const info = {
+            message: `${message} '${event.scope.name}'`
+        } as DiagnosticRelatedInformation;
+        if (isXmlScope(event.scope) && event.scope.xmlFile?.srcPath) {
+            info.location = util.createLocation(
+                URI.file(event.scope.xmlFile.srcPath).toString(),
+                util.createRange(0, 0, 0, 10)
+            );
+        } else {
+            info.location = util.createLocation(
+                URI.file(diagnostic.file.srcPath).toString(),
+                diagnostic.range
+            );
+        }
+        diagnostic.relatedInformation.push(info);
+    }
 
-        //skip enum validation if there are no enums defined in this scope
-        if (enumLookup.size === 0) {
-            return;
+    /**
+     * Find the closest symbol table for the given position
+     */
+    private getSymbolTable(scope: Scope, file: BrsFile, position: Position) {
+        let symbolTable: SymbolTable;
+        symbolTable = file.getFunctionScopeAtPosition(position)?.func.symbolTable;
+        if (!symbolTable) {
+            symbolTable = file.getNamespaceStatementForPosition(position)?.symbolTable;
         }
+        if (!symbolTable) {
+            symbolTable = scope.symbolTable;
+        }
+        return symbolTable;
+    }
 
-        event.scope.enumerateOwnFiles((file) => {
-            //skip non-brs files
-            if (!isBrsFile(file)) {
-                return;
-            }
+    private multiScopeCache = new Cache<string, BsDiagnostic>();
 
-            // const namespaceLookup = this.event.scopes[0]?.namespaceLookup;
-            for (const referenceExpression of file.parser.references.expressions) {
-                const actualExpressions: Expression[] = [];
-                //binary expressions actually have two expressions (left and right), so handle them independently
-                if (isBinaryExpression(referenceExpression)) {
-                    actualExpressions.push(referenceExpression.left, referenceExpression.right);
-                } else {
-                    //assume all other expressions are a single chain
-                    actualExpressions.push(referenceExpression);
-                }
-                for (let expression of actualExpressions) {
+    private iterateExpressions(event: OnScopeValidateEvent) {
+        const { scope } = event;
+        event.scope.enumerateOwnFiles((file) => {
+            if (isBrsFile(file)) {
+                const expressions = [
+                    ...file.parser.references.expressions,
+                    //all class "extends <whatever>" expressions
+                    ...file.parser.references.classStatements.map(x => x.parentClassName?.expression),
+                    //all interface "extends <whatever>" expressions
+                    ...file.parser.references.interfaceStatements.map(x => x.parentInterfaceName?.expression)
+                ];
+                outer:
+                for (let referenceExpression of expressions) {
+                    if (!referenceExpression) {
+                        continue;
+                    }
+                    let expression: Expression;
+                    //lift the callee from call expressions to handle namespaced function calls
+                    if (isCallExpression(referenceExpression)) {
+                        expression = referenceExpression.callee;
+                    } else if (isNewExpression(referenceExpression)) {
+                        expression = referenceExpression.call.callee;
+                    } else {
+                        expression = referenceExpression;
+                    }
                     const tokens = util.getAllDottedGetParts(expression);
-                    if (tokens) {
-                        //build the full name of the thing
-                        const parts = tokens.map(x => x.text);
-                        const fullName = parts.join('.');
-                        const memberName = parts.pop();
-                        const fullParentName = parts.join('.');
-                        const theEnum = event.scope.getEnumMap().get(fullParentName.toLowerCase())?.item;
-                        //is an enum and this field does not exist
-                        if (theEnum && !event.scope.getEnumMemberMap().has(fullName.toLowerCase())) {
-                            diagnostics.push({
-                                file: file,
-                                ...DiagnosticMessages.unknownEnumValue(memberName, fullParentName),
-                                range: tokens[tokens.length - 1].range,
-                                relatedInformation: [{
-                                    message: 'Enum declared here',
-                                    location: util.createLocation(
-                                        URI.file(file.srcPath).toString(),
-                                        theEnum.tokens.name.range
-                                    )
-                                }]
+                    if (tokens?.length > 0) {
+                        const symbolTable = this.getSymbolTable(scope, file, tokens[0].range.start); //flag all unknown left-most variables
+                        if (!symbolTable.hasSymbol(tokens[0]?.text)) {
+                            this.addMultiScopeDiagnostic(event, {
+                                file: file as BscFile,
+                                ...DiagnosticMessages.cannotFindName(tokens[0].text),
+                                range: tokens[0].range
                             });
+                            //skip to the next expression
+                            continue;
+                        }
+                        //at this point, we know the first item is a known symbol. find unknown namespace parts after the first part
+                        if (tokens.length > 1) {
+                            const firstNamespacePart = tokens.shift().text?.toLowerCase();
+                            const namespaceContainer = scope.namespaceLookup.get(firstNamespacePart);
+                            const enumStatement = scope.getEnum(firstNamespacePart);
+                            //if this isn't a namespace, skip it
+                            if (!namespaceContainer && !enumStatement) {
+                                continue;
+                            }
+                            //catch unknown namespace items
+                            const processedNames: string[] = [firstNamespacePart];
+                            for (const token of tokens ?? []) {
+                                processedNames.push(token.text?.toLowerCase());
+
+                                const entityName = processedNames.join('.');
+
+                                if (
+                                    !scope.getEnumMemberMap().has(entityName) &&
+                                    !scope.getEnumMap().has(entityName) &&
+                                    !scope.getClassMap().has(entityName) &&
+                                    !scope.getCallableByName(entityName) &&
+                                    !scope.namespaceLookup.has(entityName)
+                                ) {
+                                    //if this looks like an enum member, provide a nicer error message
+                                    const theEnum = this.getEnum(scope, entityName)?.item;
+                                    if (theEnum) {
+                                        this.addMultiScopeDiagnostic(event, {
+                                            file: file,
+                                            ...DiagnosticMessages.unknownEnumValue(token.text?.split('.').pop(), theEnum.fullName),
+                                            range: tokens[tokens.length - 1].range,
+                                            relatedInformation: [{
+                                                message: 'Enum declared here',
+                                                location: util.createLocation(
+                                                    URI.file(file.srcPath).toString(),
+                                                    theEnum.tokens.name.range
+                                                )
+                                            }]
+                                        });
+                                    } else {
+                                        this.addMultiScopeDiagnostic(event, {
+                                            ...DiagnosticMessages.cannotFindName(token.text),
+                                            range: token.range,
+                                            file: file
+                                        });
+                                    }
+                                    //no need to add another diagnostic for future unknown items
+                                    continue outer;
+                                }
+                            }
                         }
                     }
                 }
             }
         });
-        event.scope.addDiagnostics(diagnostics);
     }
 
+    /**
+     * 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 getEnum(scope: Scope, name: string) {
+        //look for the enum directly
+        let result = scope.getEnumMap().get(name);
+
+        //assume we've been given the enum.member syntax, so pop the member and try again
+        if (!result) {
+            const parts = name.split('.');
+            parts.pop();
+            result = scope.getEnumMap().get(parts.join('.'));
+        }
+        return result;
+    }
+
+    /**
+     * Flag duplicate enums
+     */
     private detectDuplicateEnums(event: OnScopeValidateEvent) {
         const diagnostics: BsDiagnostic[] = [];
         const enumLocationsByName = new Cache<string, Array<{ file: BrsFile; statement: EnumStatement }>>();
diff --git a/src/files/BrsFile.Class.spec.ts b/src/files/BrsFile.Class.spec.ts
index 20d6932f..3de70173 100644
--- a/src/files/BrsFile.Class.spec.ts
+++ b/src/files/BrsFile.Class.spec.ts
@@ -1091,7 +1091,7 @@ describe('BrsFile BrighterScript classes', () => {
             `);
             program.validate();
             expectDiagnostics(program, [{
-                ...DiagnosticMessages.classCouldNotBeFound('Animal', 'source'),
+                ...DiagnosticMessages.cannotFindName('Animal'),
                 range: Range.create(1, 35, 1, 41)
             }]);
         });
@@ -1107,7 +1107,7 @@ describe('BrsFile BrighterScript classes', () => {
             `);
             program.validate();
             expectDiagnostics(program, [
-                DiagnosticMessages.classCouldNotBeFound('Animal', 'source')
+                DiagnosticMessages.cannotFindName('Animal')
             ]);
         });
 
@@ -1125,7 +1125,7 @@ describe('BrsFile BrighterScript classes', () => {
             `);
             program.validate();
             expectDiagnostics(program, [
-                DiagnosticMessages.classCouldNotBeFound('Animal', 'source')
+                DiagnosticMessages.cannotFindName('Animal')
             ]);
         });
 
@@ -1144,7 +1144,7 @@ describe('BrsFile BrighterScript classes', () => {
             `);
             program.validate();
             expectDiagnostics(program, [
-                DiagnosticMessages.classCouldNotBeFound('Vertibrates.GroundedBird', 'source')
+                DiagnosticMessages.cannotFindName('GroundedBird')
             ]);
         });
 
@@ -1164,9 +1164,12 @@ describe('BrsFile BrighterScript classes', () => {
                 end namespace
             `);
             program.validate();
-            expectDiagnostics(program, [
-                DiagnosticMessages.classCouldNotBeFound('Vertibrates.GroundedBird', 'source')
-            ]);
+            expectDiagnostics(program, [{
+                ...DiagnosticMessages.cannotFindName('GroundedBird'),
+                relatedInformation: [{
+                    message: `Not defined in scope 'source'`
+                }]
+            }]);
         });
     });
 
@@ -1183,7 +1186,7 @@ describe('BrsFile BrighterScript classes', () => {
         `);
         program.validate();
         expectDiagnostics(program, [
-            DiagnosticMessages.classCouldNotBeFound('Duck', 'source')
+            DiagnosticMessages.cannotFindName('Duck')
         ]);
     });
 
@@ -1206,13 +1209,13 @@ describe('BrsFile BrighterScript classes', () => {
             namespace NameA.NameB
                 class Animal
                 end class
-                class Duck extends NameA.NameB.Animal1
+                class Duck extends NameA.NameB.AnimalNotDefined
                 end class
             end namespace
         `);
         program.validate();
         expectDiagnostics(program, [
-            DiagnosticMessages.classCouldNotBeFound('NameA.NameB.Animal1', 'source')
+            DiagnosticMessages.cannotFindName('AnimalNotDefined')
         ]);
     });
 
diff --git a/src/files/BrsFile.spec.ts b/src/files/BrsFile.spec.ts
index 95da9ef5..637a7299 100644
--- a/src/files/BrsFile.spec.ts
+++ b/src/files/BrsFile.spec.ts
@@ -1469,7 +1469,7 @@ describe('BrsFile', () => {
             `);
             program.validate();
             expectDiagnostics(program, [
-                DiagnosticMessages.callToUnknownFunction('DoesNotExist', 'source')
+                DiagnosticMessages.cannotFindName('DoesNotExist')
             ]);
         });
 
@@ -2141,7 +2141,7 @@ describe('BrsFile', () => {
                 testTranspile(`
                     sub main()
                         try
-                            print a.b.c
+                            print m.b.c
                         catch e
                             print e
                         end try
@@ -2160,7 +2160,7 @@ describe('BrsFile', () => {
                     sub main()
                         sayHello = NameA.NameB.Speak
                         sayHello()
-                        someOtherObject = some.other.object
+                        someOtherObject = m.other.object
                     end sub
                 `, `
                     sub NameA_NameB_Speak()
@@ -2169,7 +2169,7 @@ describe('BrsFile', () => {
                     sub main()
                         sayHello = NameA_NameB_Speak
                         sayHello()
-                        someOtherObject = some.other.object
+                        someOtherObject = m.other.object
                     end sub
                 `);
             });
@@ -2306,27 +2306,42 @@ describe('BrsFile', () => {
         });
 
         it('transpiles dim', () => {
-            testTranspile(`Dim c[5]`, `Dim c[5]`);
-            testTranspile(`Dim c[5, 4]`, `Dim c[5, 4]`);
-            testTranspile(`Dim c[5, 4, 6]`, `Dim c[5, 4, 6]`);
-            testTranspile(`Dim requestData[requestList.count()]`, `Dim requestData[requestList.count()]`);
-            testTranspile(`Dim requestData[1, requestList.count()]`, `Dim requestData[1, requestList.count()]`);
-            testTranspile(`Dim requestData[1, requestList.count(), 2]`, `Dim requestData[1, requestList.count(), 2]`);
-            testTranspile(`Dim requestData[requestList[2]]`, `Dim requestData[requestList[2]]`);
-            testTranspile(`Dim requestData[1, requestList[2]]`, `Dim requestData[1, requestList[2]]`);
-            testTranspile(`Dim requestData[1, requestList[2], 2]`, `Dim requestData[1, requestList[2], 2]`);
-            testTranspile(`Dim requestData[requestList["2"]]`, `Dim requestData[requestList["2"]]`);
-            testTranspile(`Dim requestData[1, requestList["2"]]`, `Dim requestData[1, requestList["2"]]`);
-            testTranspile(`Dim requestData[1, requestList["2"], 2]`, `Dim requestData[1, requestList["2"], 2]`);
-            testTranspile(`Dim requestData[1, getValue(), 2]`, `Dim requestData[1, getValue(), 2]`);
+            function doTest(code: string) {
+                testTranspile(`
+                    sub main()
+                        requestList = []
+                        ${code}
+                    end sub
+                `, `
+                    sub main()
+                        requestList = []
+                        ${code}
+                    end sub
+                `);
+            }
+            doTest(`Dim c[5]`);
+            doTest(`Dim c[5, 4]`);
+            doTest(`Dim c[5, 4, 6]`);
+            doTest(`Dim requestData[requestList.count()]`);
+            doTest(`Dim requestData[1, requestList.count()]`);
+            doTest(`Dim requestData[1, requestList.count(), 2]`);
+            doTest(`Dim requestData[requestList[2]]`);
+            doTest(`Dim requestData[1, requestList[2]]`);
+            doTest(`Dim requestData[1, requestList[2], 2]`);
+            doTest(`Dim requestData[requestList["2"]]`);
+            doTest(`Dim requestData[1, requestList["2"]]`);
+            doTest(`Dim requestData[1, requestList["2"], 2]`);
+            doTest(`Dim requestData[1, StrToI("1"), 2]`);
             testTranspile(`
-                Dim requestData[1, getValue({
-                    key: "value"
-                }), 2]
-            `, `
-                Dim requestData[1, getValue({
-                    key: "value"
-                }), 2]
+                function getValue(param1)
+                end function
+
+                sub main()
+                    requestList = []
+                    Dim requestData[1, getValue({
+                        key: "value"
+                    }), 2]
+                end sub
             `);
         });
 
@@ -2611,7 +2626,7 @@ describe('BrsFile', () => {
                         3 'comment
                     ] 'comment
                     firstIndex = indexes[0] 'comment
-                    for each idx in indxes 'comment
+                    for each idx in indexes 'comment
                         indexes[idx] = idx + 1 'comment
                     end for 'comment
                     if not true then 'comment
@@ -2696,8 +2711,9 @@ describe('BrsFile', () => {
         describe('transpile', () => {
             it('does not produce diagnostics', () => {
                 program.setFile('source/main.bs', `
-                    sub main()
-                        someObject@.someFunction(paramObject.value)
+                    sub test()
+                        someNode = createObject("roSGNode", "Rectangle")
+                        someNode@.someFunction(test.value)
                     end sub
                 `);
                 program.validate();
diff --git a/src/files/BrsFile.ts b/src/files/BrsFile.ts
index 2ef98fe4..d8142b4e 100644
--- a/src/files/BrsFile.ts
+++ b/src/files/BrsFile.ts
@@ -120,12 +120,16 @@ export class BrsFile {
         return this._functionScopes;
     }
 
+    private get cache() {
+        // eslint-disable-next-line @typescript-eslint/dot-notation
+        return this._parser?.references['cache'];
+    }
+
     /**
      * files referenced by import statements
      */
     public get ownScriptImports() {
-        // eslint-disable-next-line @typescript-eslint/dot-notation
-        const result = this._parser?.references['cache'].getOrAdd('BrsFile_ownScriptImports', () => {
+        const result = this.cache?.getOrAdd('BrsFile_ownScriptImports', () => {
             const result = [] as FileReference[];
             for (const statement of this.parser?.references?.importStatements ?? []) {
                 //register import statements
@@ -710,14 +714,20 @@ export class BrsFile {
      * @param position
      * @param functionScopes
      */
-    public getFunctionScopeAtPosition(position: Position, functionScopes?: FunctionScope[]): FunctionScope {
+    public getFunctionScopeAtPosition(position: Position): FunctionScope {
+        return this.cache.getOrAdd(`functionScope-${position.line}:${position.character}`, () => {
+            return this._getFunctionScopeAtPosition(position, this.functionScopes);
+        });
+    }
+
+    public _getFunctionScopeAtPosition(position: Position, functionScopes?: FunctionScope[]): FunctionScope {
         if (!functionScopes) {
             functionScopes = this.functionScopes;
         }
         for (let scope of functionScopes) {
             if (util.rangeContains(scope.range, position)) {
                 //see if any of that scope's children match the position also, and give them priority
-                let childScope = this.getFunctionScopeAtPosition(position, scope.childrenScopes);
+                let childScope = this._getFunctionScopeAtPosition(position, scope.childrenScopes);
                 if (childScope) {
                     return childScope;
                 } else {
@@ -734,11 +744,13 @@ export class BrsFile {
      */
     public getNamespaceStatementForPosition(position: Position): NamespaceStatement {
         if (position) {
-            for (const statement of this.parser.references.namespaceStatements) {
-                if (util.rangeContains(statement.range, position)) {
-                    return statement;
+            return this.cache.getOrAdd(`namespaceStatementForPosition-${position.line}:${position.character}`, () => {
+                for (const statement of this.parser.references.namespaceStatements) {
+                    if (util.rangeContains(statement.range, position)) {
+                        return statement;
+                    }
                 }
-            }
+            });
         }
     }
 
diff --git a/src/globalCallables.ts b/src/globalCallables.ts
index c7367e06..a84eca8a 100644
--- a/src/globalCallables.ts
+++ b/src/globalCallables.ts
@@ -1015,6 +1015,9 @@ for (let callable of globalCallables) {
 
 }
 globalFile.callables = globalCallables as Callable[];
+for (const callable of globalCallables) {
+    globalFile.parser.symbolTable.addSymbol(callable.name, undefined, callable.type);
+}
 
 /**
  * A map of all built-in function names. We use this extensively in scope validation
diff --git a/src/parser/Expression.ts b/src/parser/Expression.ts
index 271d1d1f..6ef5dc2d 100644
--- a/src/parser/Expression.ts
+++ b/src/parser/Expression.ts
@@ -15,6 +15,7 @@ import { VoidType } from '../types/VoidType';
 import { DynamicType } from '../types/DynamicType';
 import type { BscType } from '../types/BscType';
 import { FunctionType } from '../types/FunctionType';
+import { SymbolTable } from '../SymbolTable';
 
 export type ExpressionVisitor = (expression: Expression, parent: Expression) => void;
 
@@ -135,7 +136,8 @@ export class FunctionExpression extends Expression implements TypedefProvider {
          * If this function is enclosed within another function, this will reference that parent function
          */
         readonly parentFunction?: FunctionExpression,
-        readonly namespaceName?: NamespacedVariableNameExpression
+        readonly namespaceName?: NamespacedVariableNameExpression,
+        readonly parentSymbolTable?: SymbolTable
     ) {
         super();
         if (this.returnTypeToken) {
@@ -143,10 +145,16 @@ export class FunctionExpression extends Expression implements TypedefProvider {
         } else if (this.functionType.text.toLowerCase() === 'sub') {
             this.returnType = new VoidType();
         } else {
-            this.returnType = new DynamicType();
+            this.returnType = DynamicType.instance;
+        }
+        this.symbolTable = new SymbolTable(parentSymbolTable);
+        for (let param of parameters) {
+            this.symbolTable.addSymbol(param.name.text, param.name.range, DynamicType.instance);
         }
     }
 
+    public symbolTable: SymbolTable;
+
     /**
      * The type this function returns
      */
diff --git a/src/parser/Parser.ts b/src/parser/Parser.ts
index b66dcaf3..1c890c04 100644
--- a/src/parser/Parser.ts
+++ b/src/parser/Parser.ts
@@ -93,6 +93,8 @@ import { isAAMemberExpression, isAnnotationExpression, isBinaryExpression, isCal
 import { createVisitor, WalkMode } from '../astUtils/visitors';
 import { createStringLiteral, createToken } from '../astUtils/creators';
 import { Cache } from '../Cache';
+import { SymbolTable } from '../SymbolTable';
+import { DynamicType } from '../types/DynamicType';
 
 export class Parser {
     /**
@@ -114,6 +116,15 @@ export class Parser {
         return this.ast.statements;
     }
 
+    /**
+     * The top-level symbol table for this file. Things like top-level namespaces, non-namespaced classes, enums, interfaces, and functions beling here.
+     */
+    public symbolTable = new SymbolTable();
+
+    private get currentSymbolTable() {
+        return this.currentFunctionExpression?.symbolTable ?? this.currentNamespace?.symbolTable ?? this.symbolTable;
+    }
+
     /**
      * References for significant statements/expressions in the parser.
      * These are initially extracted during parse-time to improve performance, but will also be dynamically regenerated if need be.
@@ -174,7 +185,11 @@ export class Parser {
      * When a namespace has been started, this gets set. When it's done, this gets unset.
      * It is useful for passing the namespace into certain statements that need it
      */
-    private currentNamespaceName: NamespacedVariableNameExpression;
+    private currentNamespace: NamespaceStatement;
+
+    private get currentNamespaceName(): NamespacedVariableNameExpression {
+        return this.currentNamespace?.nameExpression;
+    }
 
     /**
      * When a FunctionExpression has been started, this gets set. When it's done, this gets unset.
@@ -580,6 +595,10 @@ export class Parser {
         //consume the final `end interface` token
         result.tokens.endEnum = this.consumeToken(TokenKind.EndEnum);
 
+        if (result.name) {
+            this.currentSymbolTable.addSymbol(result.tokens.name.text, result.tokens.name.range, DynamicType.instance);
+        }
+
         this._references.enumStatements.push(result);
         this.exitAnnotationBlock(parentAnnotations);
         return result;
@@ -658,6 +677,9 @@ export class Parser {
                     //refer to this statement as parent of the expression
                     functionStatement.func.functionStatement = decl as MethodStatement;
 
+                    //add the `super` symbol to class methods
+                    funcDeclaration.func.symbolTable.addSymbol('super', undefined, DynamicType.instance);
+
                     //fields
                 } else if (this.checkAny(TokenKind.Identifier, ...AllowedProperties)) {
 
@@ -707,6 +729,10 @@ export class Parser {
             this.currentNamespaceName
         );
 
+        if (className) {
+            this.currentSymbolTable.addSymbol(className.text, className.range, DynamicType.instance);
+        }
+
         this._references.classStatements.push(result);
         this.exitAnnotationBlock(parentAnnotations);
         return result;
@@ -757,9 +783,9 @@ export class Parser {
      */
     private callExpressions = [];
 
-    private functionDeclaration(isAnonymous: true, checkIdentifier?: boolean): FunctionExpression;
-    private functionDeclaration(isAnonymous: false, checkIdentifier?: boolean): FunctionStatement;
-    private functionDeclaration(isAnonymous: boolean, checkIdentifier = true) {
+    private functionDeclaration(isAnonymous: true, checkIdentifier?: boolean, onlyCallableAsMember?: boolean): FunctionExpression;
+    private functionDeclaration(isAnonymous: false, checkIdentifier?: boolean, onlyCallableAsMember?: boolean): FunctionStatement;
+    private functionDeclaration(isAnonymous: boolean, checkIdentifier = true, onlyCallableAsMember = false) {
         let previousCallExpressions = this.callExpressions;
         this.callExpressions = [];
         try {
@@ -878,13 +904,26 @@ export class Parser {
                 asToken,
                 typeToken,
                 this.currentFunctionExpression,
-                this.currentNamespaceName
+                this.currentNamespaceName,
+                this.currentSymbolTable
             );
+            if (!func.symbolTable.hasSymbol('m')) {
+                func.symbolTable.addSymbol('m', undefined, DynamicType.instance);
+            }
             //if there is a parent function, register this function with the parent
             if (this.currentFunctionExpression) {
                 this.currentFunctionExpression.childFunctionExpressions.push(func);
             }
 
+            // add the function to the relevant symbol tables
+            if (!onlyCallableAsMember && name) {
+                const funcType = func.getFunctionType();
+                funcType.setName(name.text);
+
+                // add the function as declared to the current symbol table
+                this.currentSymbolTable.addSymbol(name.text, name.range, funcType);
+            }
+
             this._references.functionExpressions.push(func);
 
             let previousFunctionExpression = this.currentFunctionExpression;
@@ -1011,6 +1050,8 @@ export class Parser {
             this._references.expressions.delete(value);
             this._references.expressions.add(result);
         }
+
+        this.currentSymbolTable.addSymbol(name.text, name.range, DynamicType.instance);
         this._references.assignmentStatements.push(result);
         return result;
     }
@@ -1246,6 +1287,8 @@ export class Parser {
             throw this.lastDiagnosticAsError();
         }
 
+        this.currentSymbolTable.addSymbol(name.text, name.range, DynamicType.instance);
+
         this.consumeStatementSeparators();
 
         let body = this.block(TokenKind.EndFor, TokenKind.Next);
@@ -1306,16 +1349,16 @@ export class Parser {
         this.namespaceAndFunctionDepth++;
 
         let name = this.getNamespacedVariableNameExpression();
-
         //set the current namespace name
-        this.currentNamespaceName = name;
+        let result = new NamespaceStatement(keyword, name, null, null, this.currentSymbolTable);
+        this.currentNamespace = result;
 
         this.globalTerminators.push([TokenKind.EndNamespace]);
         let body = this.body();
         this.globalTerminators.pop();
 
         //unset the current namespace name
-        this.currentNamespaceName = undefined;
+        this.currentNamespace = undefined;
 
         let endKeyword: Token;
         if (this.check(TokenKind.EndNamespace)) {
@@ -1329,8 +1372,19 @@ export class Parser {
         }
 
         this.namespaceAndFunctionDepth--;
-        let result = new NamespaceStatement(keyword, name, body, endKeyword);
+        result.body = body;
+        result.endKeyword = endKeyword;
         this._references.namespaceStatements.push(result);
+        //cache the range property so that plugins can't affect it
+        result.cacheRange();
+
+        if (result.name) {
+            this.symbolTable.addSymbol(
+                result.name.split('.')[0],
+                result.nameExpression.range,
+                DynamicType.instance
+            );
+        }
 
         return result;
     }
@@ -1626,6 +1680,9 @@ export class Parser {
         } else {
             statement.tokens.endTry = this.advance();
         }
+        if (exceptionVarToken) {
+            this.currentSymbolTable.addSymbol(exceptionVarToken.text, exceptionVarToken.range, DynamicType.instance);
+        }
         return statement;
     }
 
diff --git a/src/parser/Statement.ts b/src/parser/Statement.ts
index 3ca1aee2..da501a05 100644
--- a/src/parser/Statement.ts
+++ b/src/parser/Statement.ts
@@ -17,6 +17,7 @@ import { DynamicType } from '../types/DynamicType';
 import type { BscType } from '../types/BscType';
 import type { SourceNode } from 'source-map';
 import type { TranspileState } from './TranspileState';
+import { SymbolTable } from '../SymbolTable';
 
 /**
  * A BrightScript statement
@@ -1099,22 +1100,36 @@ export class NamespaceStatement extends Statement implements TypedefProvider {
         //this should technically only be a VariableExpression or DottedGetExpression, but that can be enforced elsewhere
         public nameExpression: NamespacedVariableNameExpression,
         public body: Body,
-        public endKeyword: Token
+        public endKeyword: Token,
+        readonly parentSymbolTable?: SymbolTable
     ) {
         super();
         this.name = this.nameExpression.getName(ParseMode.BrighterScript);
+        this.symbolTable = new SymbolTable(parentSymbolTable);
     }
 
+    public symbolTable: SymbolTable;
+
     /**
      * The string name for this namespace
      */
     public name: string;
 
     public get range() {
-        return util.createRangeFromPositions(
-            this.keyword.range.start,
-            (this.endKeyword ?? this.body ?? this.nameExpression ?? this.keyword).range.end
-        );
+        return this.cacheRange();
+    }
+    private _range: Range;
+
+    public cacheRange() {
+        if (!this._range) {
+            this._range = util.createBoundingRange(
+                this.keyword,
+                this.nameExpression,
+                this.body,
+                this.endKeyword
+            ) ?? interpolatedRange;
+        }
+        return this._range;
     }
 
     public getName(parseMode: ParseMode) {
@@ -1523,6 +1538,7 @@ export class ClassStatement extends Statement implements TypedefProvider {
     ) {
         super();
         this.body = this.body ?? [];
+
         for (let statement of this.body) {
             if (isMethodStatement(statement)) {
                 this.methods.push(statement);
diff --git a/src/types/DynamicType.ts b/src/types/DynamicType.ts
index 19de2c13..5b156bb4 100644
--- a/src/types/DynamicType.ts
+++ b/src/types/DynamicType.ts
@@ -5,6 +5,8 @@ export class DynamicType implements BscType {
         public typeText?: string
     ) { }
 
+    public static readonly instance = new DynamicType();
+
     public isAssignableTo(targetType: BscType) {
         //everything can be dynamic, so as long as a type is provided, this is true
         return !!targetType;
diff --git a/src/util.spec.ts b/src/util.spec.ts
index 97a249a6..7896cb1d 100644
--- a/src/util.spec.ts
+++ b/src/util.spec.ts
@@ -5,6 +5,7 @@ import { Position, Range } from 'vscode-languageserver';
 import type { BsConfig } from './BsConfig';
 import * as fsExtra from 'fs-extra';
 import { createSandbox } from 'sinon';
+import { DiagnosticMessages } from './DiagnosticMessages';
 const sinon = createSandbox();
 let tempDir = s`${process.cwd()}/.tmp`;
 let rootDir = s`${tempDir}/rootDir`;
@@ -663,4 +664,49 @@ describe('util', () => {
             }]);
         });
     });
+
+    describe('toDiagnostic', () => {
+        it('uses a uri on relatedInfo missing location', () => {
+            expect(
+                util.toDiagnostic({
+                    ...DiagnosticMessages.cannotFindName('someVar'),
+                    file: undefined,
+                    range: util.createRange(1, 2, 3, 4),
+                    relatedInformation: [{
+                        message: 'Alpha',
+                        location: undefined
+                    }]
+                }, 'u/r/i').relatedInformation
+            ).to.eql([{
+                message: 'Alpha',
+                location: util.createLocation(
+                    'u/r/i', util.createRange(1, 2, 3, 4)
+                )
+            }]);
+        });
+
+        it('eliminates diagnostics with relatedInformation that are missing a uri', () => {
+            expect(
+                util.toDiagnostic({
+                    ...DiagnosticMessages.cannotFindName('someVar'),
+                    file: undefined,
+                    range: util.createRange(1, 2, 3, 4),
+                    relatedInformation: [{
+                        message: 'Alpha',
+                        location: util.createLocation(
+                            'uri', util.createRange(2, 3, 4, 5)
+                        )
+                    }, {
+                        message: 'Beta',
+                        location: undefined
+                    }]
+                }, undefined).relatedInformation
+            ).to.eql([{
+                message: 'Alpha',
+                location: util.createLocation(
+                    'uri', util.createRange(2, 3, 4, 5)
+                )
+            }]);
+        });
+    });
 });
diff --git a/src/util.ts b/src/util.ts
index 4c1a812c..897414c0 100644
--- a/src/util.ts
+++ b/src/util.ts
@@ -877,7 +877,7 @@ export class Util {
     }
 
     /**
-     * Helper for creating `Range` objects. Prefer using this function because vscode-languageserver's `util.createRange()` is significantly slower
+     * Helper for creating `Range` objects. Prefer using this function because vscode-languageserver's `Range.create()` is significantly slower
      */
     public createRange(startLine: number, startCharacter: number, endLine: number, endCharacter: number): Range {
         return {
@@ -1217,17 +1217,31 @@ export class Util {
     }
 
     /**
-     * Given a Diagnostic or BsDiagnostic, return a copy of the diagnostic
+     * Given a Diagnostic or BsDiagnostic, return a deep clone of the diagnostic.
+     * @param diagnostic the diagnostic to clone
+     * @param relatedInformationFallbackLocation a default location to use for all `relatedInformation` entries that are missing a location
      */
-    public toDiagnostic(diagnostic: Diagnostic | BsDiagnostic) {
+    public toDiagnostic(diagnostic: Diagnostic | BsDiagnostic, fileUri: string) {
         return {
             severity: diagnostic.severity,
             range: diagnostic.range,
             message: diagnostic.message,
             relatedInformation: diagnostic.relatedInformation?.map(x => {
+
                 //clone related information just in case a plugin added circular ref info here
-                return { ...x };
-            }),
+                const clone = { ...x };
+                if (!clone.location) {
+                    // use the fallback location if available
+                    if (fileUri) {
+                        clone.location = util.createLocation(fileUri, diagnostic.range);
+                    } else {
+                        //remove this related information so it doesn't bring crash the language server
+                        return undefined;
+                    }
+                }
+                return clone;
+                //filter out null relatedInformation items
+            }).filter(x => x),
             code: diagnostic.code,
             source: 'brs'
         };
diff --git a/src/validators/ClassValidator.ts b/src/validators/ClassValidator.ts
index 7e388e2b..9d4ba253 100644
--- a/src/validators/ClassValidator.ts
+++ b/src/validators/ClassValidator.ts
@@ -77,13 +77,8 @@ export class BsClassValidator {
                             range: newExpression.className.range
                         });
 
-                        //could not find a class with this name
                     } else {
-                        this.diagnostics.push({
-                            ...DiagnosticMessages.classCouldNotBeFound(className, this.scope.name),
-                            file: file,
-                            range: newExpression.className.range
-                        });
+                        //could not find a class with this name (handled by ScopeValidator)
                     }
                 }
             }
@@ -427,20 +422,15 @@ export class BsClassValidator {
                 } else if (absoluteParent) {
                     parentClass = absoluteParent;
 
-                    //couldn't find the parent class
                 } else {
-                    this.diagnostics.push({
-                        ...DiagnosticMessages.classCouldNotBeFound(parentClassName, this.scope.name),
-                        file: classStatement.file,
-                        range: classStatement.parentClassName.range
-                    });
+                    //couldn't find the parent class (validated in ScopeValidator)
                 }
                 classStatement.parentClass = parentClass;
             }
         }
     }
-
 }
+
 type AugmentedClassStatement = ClassStatement & {
     file: BscFile;
     parentClass: AugmentedClassStatement;


Test patch:
diff --git a/src/files/tests/imports.spec.ts b/src/files/tests/imports.spec.ts
index 415e038d..aa1738dd 100644
--- a/src/files/tests/imports.spec.ts
+++ b/src/files/tests/imports.spec.ts
@@ -158,7 +158,7 @@ describe('import statements', () => {
         program.validate();
 
         expectDiagnostics(program, [
-            DiagnosticMessages.callToUnknownFunction('Waddle', s`components/ChildScene.xml`).message
+            DiagnosticMessages.cannotFindName('Waddle')
         ]);
 
         //add the missing function
diff --git a/src/files/tests/optionalChaning.spec.ts b/src/files/tests/optionalChaning.spec.ts
index e25f82b6..eb66538c 100644
--- a/src/files/tests/optionalChaning.spec.ts
+++ b/src/files/tests/optionalChaning.spec.ts
@@ -73,10 +73,12 @@ describe('optional chaining', () => {
 
     it('transpiles various use cases', () => {
         testTranspile(`
+            obj = {}
+            arr = []
             print arr?.["0"]
             print arr?.value
-            print assocArray?.[0]
-            print assocArray?.getName()?.first?.second
+            print obj?.[0]
+            print obj?.getName()?.first?.second
             print createObject("roByteArray")?.value
             print createObject("roByteArray")?["0"]
             print createObject("roList")?.value
@@ -85,10 +87,10 @@ describe('optional chaining', () => {
             print createObject("roDateTime")?.value
             print createObject("roDateTime")?.GetTimeZoneOffset
             print createObject("roSGNode", "Node")?[0]
-            print pi?.first?.second
-            print success?.first?.second
-            print a.b.xmlThing?@someAttr
-            print a.b.localFunc?()
+            print obj?.first?.second
+            print obj?.first?.second
+            print obj.b.xmlThing?@someAttr
+            print obj.b.localFunc?()
         `);
     });
 });
diff --git a/src/parser/tests/expression/NullCoalescenceExpression.spec.ts b/src/parser/tests/expression/NullCoalescenceExpression.spec.ts
index ca6ebad3..94872ca2 100644
--- a/src/parser/tests/expression/NullCoalescenceExpression.spec.ts
+++ b/src/parser/tests/expression/NullCoalescenceExpression.spec.ts
@@ -202,43 +202,61 @@ describe('NullCoalescingExpression', () => {
         });
 
         it('properly transpiles null coalesence assignments - complex consequent', () => {
-            testTranspile(`a = user.getAccount() ?? {"id": "default"}`, `
-                a = (function(user)
-                        __bsConsequent = user.getAccount()
-                        if __bsConsequent <> invalid then
-                            return __bsConsequent
-                        else
-                            return {
-                                "id": "default"
-                            }
-                        end if
-                    end function)(user)
+            testTranspile(`
+                sub main()
+                    user = {}
+                    a = user.getAccount() ?? {"id": "default"}
+                end sub
+            `, `
+                sub main()
+                    user = {}
+                    a = (function(user)
+                            __bsConsequent = user.getAccount()
+                            if __bsConsequent <> invalid then
+                                return __bsConsequent
+                            else
+                                return {
+                                    "id": "default"
+                                }
+                            end if
+                        end function)(user)
+                end sub
             `);
         });
 
         it('transpiles null coalesence assignment for variable alternate- complex consequent', () => {
-            testTranspile(`a = obj.link ?? fallback`, `
-                a = (function(fallback, obj)
+            testTranspile(`a = obj.link ?? false`, `
+                a = (function(obj)
                         __bsConsequent = obj.link
                         if __bsConsequent <> invalid then
                             return __bsConsequent
                         else
-                            return fallback
+                            return false
                         end if
-                    end function)(fallback, obj)
+                    end function)(obj)
             `);
         });
 
         it('properly transpiles null coalesence assignments - complex alternate', () => {
-            testTranspile(`a = user ?? m.defaults.getAccount(settings.name)`, `
-                a = (function(m, settings, user)
-                        __bsConsequent = user
-                        if __bsConsequent <> invalid then
-                            return __bsConsequent
-                        else
-                            return m.defaults.getAccount(settings.name)
-                        end if
-                    end function)(m, settings, user)
+            testTranspile(`
+                sub main()
+                    user = {}
+                    settings = {}
+                    a = user ?? m.defaults.getAccount(settings.name)
+                end sub
+            `, `
+                sub main()
+                    user = {}
+                    settings = {}
+                    a = (function(m, settings, user)
+                            __bsConsequent = user
+                            if __bsConsequent <> invalid then
+                                return __bsConsequent
+                            else
+                                return m.defaults.getAccount(settings.name)
+                            end if
+                        end function)(m, settings, user)
+                end sub
             `);
         });
     });
diff --git a/src/parser/tests/expression/TemplateStringExpression.spec.ts b/src/parser/tests/expression/TemplateStringExpression.spec.ts
index e555890c..3760777b 100644
--- a/src/parser/tests/expression/TemplateStringExpression.spec.ts
+++ b/src/parser/tests/expression/TemplateStringExpression.spec.ts
@@ -77,15 +77,15 @@ describe('TemplateStringExpression', () => {
         it('uses the proper prefix when aliased package is installed', () => {
             program.setFile('source/roku_modules/rokucommunity_bslib/bslib.brs', '');
             testTranspile(
-                'a = `${one},${two}`',
-                `a = rokucommunity_bslib_toString(one) + "," + rokucommunity_bslib_toString(two)`
+                'a = `${LINE_NUM},${LINE_NUM}`',
+                `a = rokucommunity_bslib_toString(LINE_NUM) + "," + rokucommunity_bslib_toString(LINE_NUM)`
             );
         });
 
         it('properly transpiles simple template string with no leading text', () => {
             testTranspile(
-                'a = `${one},${two}`',
-                `a = bslib_toString(one) + "," + bslib_toString(two)`
+                'a = `${LINE_NUM},${LINE_NUM}`',
+                `a = bslib_toString(LINE_NUM) + "," + bslib_toString(LINE_NUM)`
             );
         });
 
@@ -98,8 +98,8 @@ describe('TemplateStringExpression', () => {
 
         it('properly transpiles one line template string with expressions', () => {
             testTranspile(
-                'a = `hello ${a.text} world ${"template" + m.getChars()} test`',
-                `a = "hello " + bslib_toString(a.text) + " world " + bslib_toString("template" + m.getChars()) + " test"`
+                'a = `hello ${LINE_NUM.text} world ${"template" + "".getChars()} test`',
+                `a = "hello " + bslib_toString(LINE_NUM.text) + " world " + bslib_toString("template" + "".getChars()) + " test"`
             );
         });
 
@@ -174,7 +174,7 @@ describe('TemplateStringExpression', () => {
                         "a",
                         "b",
                         "c",
-                        \`d_open \${"inside" + m.items[i]} d_close\`
+                        \`d_open \${"inside" + m.items[1]} d_close\`
                     ])}\`
                 ]
             `, `
@@ -185,7 +185,7 @@ describe('TemplateStringExpression', () => {
                         "a"
                         "b"
                         "c"
-                        "d_open " + bslib_toString("inside" + m.items[i]) + " d_close"
+                        "d_open " + bslib_toString("inside" + m.items[1]) + " d_close"
                     ]))
                 ]
             `);
diff --git a/src/parser/tests/expression/TernaryExpression.spec.ts b/src/parser/tests/expression/TernaryExpression.spec.ts
index f2df2403..32d7d7c4 100644
--- a/src/parser/tests/expression/TernaryExpression.spec.ts
+++ b/src/parser/tests/expression/TernaryExpression.spec.ts
@@ -267,87 +267,185 @@ describe('ternary expressions', () => {
 
         it('uses the proper prefix when aliased package is installed', () => {
             program.setFile('source/roku_modules/rokucommunity_bslib/bslib.brs', '');
-            testTranspile(
-                `a = user = invalid ? "no user" : "logged in"`,
-                `a = rokucommunity_bslib_ternary(user = invalid, "no user", "logged in")`
-            );
+            testTranspile(`
+                sub main()
+                    user = {}
+                    a = user = invalid ? "no user" : "logged in"
+                end sub
+            `, `
+                sub main()
+                    user = {}
+                    a = rokucommunity_bslib_ternary(user = invalid, "no user", "logged in")
+                end sub
+            `);
         });
 
         it('simple consequents', () => {
-            testTranspile(
-                `a = user = invalid ? "no user" : "logged in"`,
-                `a = bslib_ternary(user = invalid, "no user", "logged in")`
-            );
+            testTranspile(`
+                sub main()
+                    user = {}
+                    a = user = invalid ? "no user" : "logged in"
+                end sub
+            `, `
+                sub main()
+                    user = {}
+                    a = bslib_ternary(user = invalid, "no user", "logged in")
+                end sub
+            `);
 
-            testTranspile(
-                `a = user = invalid ? 1 : "logged in"`,
-                `a = bslib_ternary(user = invalid, 1, "logged in")`
-            );
+            testTranspile(`
+                sub main()
+                    user = {}
+                    a = user = invalid ? 1 : "logged in"
+                end sub
+            `, `
+                sub main()
+                    user = {}
+                    a = bslib_ternary(user = invalid, 1, "logged in")
+                end sub
+            `);
 
-            testTranspile(
-                `a = user = invalid ? 1.2 : "logged in"`,
-                `a = bslib_ternary(user = invalid, 1.2, "logged in")`
-            );
+            testTranspile(`
+                sub main()
+                    user = {}
+                    a = user = invalid ? 1.2 : "logged in"
+                end sub
+            `, `
+                sub main()
+                    user = {}
+                    a = bslib_ternary(user = invalid, 1.2, "logged in")
+                end sub
+            `);
 
-            testTranspile(
-                `a = user = invalid ? {} : "logged in"`,
-                `a = bslib_ternary(user = invalid, {}, "logged in")`
-            );
+            testTranspile(`
+                sub main()
+                    user = {}
+                    a = user = invalid ? {} : "logged in"
+                end sub
+            `, `
+                sub main()
+                    user = {}
+                    a = bslib_ternary(user = invalid, {}, "logged in")
+                end sub
+            `);
 
-            testTranspile(
-                `a = user = invalid ? [] : "logged in"`,
-                `a = bslib_ternary(user = invalid, [], "logged in")`
-            );
+            testTranspile(`
+                sub main()
+                    user = {}
+                    a = user = invalid ? [] : "logged in"
+                end sub
+            `, `
+                sub main()
+                    user = {}
+                    a = bslib_ternary(user = invalid, [], "logged in")
+                end sub
+            `);
         });
 
         it('simple alternates', () => {
-            testTranspile(
-                `a = user = invalid ? "logged in" : "no user" `,
-                `a = bslib_ternary(user = invalid, "logged in", "no user")`
-            );
+            testTranspile(`
+                sub main()
+                    user = {}
+                    a = user = invalid ? "logged in" : "no user"
+                end sub
+            `, `
+                sub main()
+                    user = {}
+                    a = bslib_ternary(user = invalid, "logged in", "no user")
+                end sub
+            `);
 
-            testTranspile(
-                `a = user = invalid ? "logged in" : 1 `,
-                `a = bslib_ternary(user = invalid, "logged in", 1)`
-            );
+            testTranspile(`
+                sub main()
+                    user = {}
+                    a = user = invalid ? "logged in" : 1
+                end sub
+            `, `
+                sub main()
+                    user = {}
+                    a = bslib_ternary(user = invalid, "logged in", 1)
+                end sub
+            `);
 
-            testTranspile(
-                `a = user = invalid ? "logged in" : 1.2 `,
-                `a = bslib_ternary(user = invalid, "logged in", 1.2)`
-            );
+            testTranspile(`
+                sub main()
+                    user = {}
+                    a = user = invalid ? "logged in" : 1.2
+                end sub
+            `, `
+                sub main()
+                    user = {}
+                    a = bslib_ternary(user = invalid, "logged in", 1.2)
+                end sub
+            `);
 
-            testTranspile(
-                `a = user = invalid ? "logged in" :  [] `,
-                `a = bslib_ternary(user = invalid, "logged in", [])`
-            );
+            testTranspile(`
+                sub main()
+                    user = {}
+                    a = user = invalid ? "logged in" :  []
+                end sub
+            `, `
+                sub main()
+                    user = {}
+                    a = bslib_ternary(user = invalid, "logged in", [])
+                end sub
+            `);
 
-            testTranspile(
-                `a = user = invalid ? "logged in" :  {} `,
-                `a = bslib_ternary(user = invalid, "logged in", {})`
-            );
+            testTranspile(`
+                sub main()
+                    user = {}
+                    a = user = invalid ? "logged in" :  {}
+                end sub
+            `, `
+                sub main()
+                    user = {}
+                    a = bslib_ternary(user = invalid, "logged in", {})
+                end sub
+            `);
         });
 
         it('complex conditions do not cause scope capture', () => {
             testTranspile(
-                `a = IsTrue() = true ? true : false `,
-                `a = bslib_ternary(IsTrue() = true, true, false)`
+                `a = str("true") = "true" ? true : false `,
+                `a = bslib_ternary(str("true") = "true", true, false)`
             );
 
-            testTranspile(
-                `a = m.top.service.IsTrue() ? true : false `,
-                `a = bslib_ternary(m.top.service.IsTrue(), true, false)`
-            );
+            testTranspile(`
+                sub main()
+                    a = m.top.service.IsTrue() ? true : false
+                end sub
+            `, `
+                sub main()
+                    a = bslib_ternary(m.top.service.IsTrue(), true, false)
+                end sub
+            `);
 
-            testTranspile(
-                `a = First(second(third(fourth(m.fifth()[123].truthy(1))))) ? true : false `,
-                `a = bslib_ternary(First(second(third(fourth(m.fifth()[123].truthy(1))))), true, false)`
-            );
+            testTranspile(`
+                sub test(param1)
+                end sub
+
+                sub main()
+                    a = test(test(test(test(m.fifth()[123].truthy(1))))) ? true : false
+                end sub
+            `, `
+                sub test(param1)
+                end sub
+
+                sub main()
+                    a = bslib_ternary(test(test(test(test(m.fifth()[123].truthy(1))))), true, false)
+                end sub
+            `);
         });
 
         it('captures scope for function call conseqent', () => {
-            testTranspile(
-                `name = zombie.getName() <> invalid ? zombie.GetName() : "zombie"`,
-                `
+            testTranspile(`
+                sub main()
+                    zombie = {}
+                    name = zombie.getName() <> invalid ? zombie.GetName() : "zombie"
+                end sub
+            `, `
+                sub main()
+                    zombie = {}
                     name = (function(__bsCondition, zombie)
                             if __bsCondition then
                                 return zombie.GetName()
@@ -355,14 +453,19 @@ describe('ternary expressions', () => {
                                 return "zombie"
                             end if
                         end function)(zombie.getName() <> invalid, zombie)
-                `
-            );
+                end sub
+            `);
         });
 
         it('captures scope for function call alternate', () => {
-            testTranspile(
-                `name = zombie.getName() = invalid ? "zombie" :  zombie.GetName()`,
-                `
+            testTranspile(`
+                sub main()
+                    zombie = {}
+                    name = zombie.getName() = invalid ? "zombie" :  zombie.GetName()
+                end sub
+            `, `
+                sub main()
+                    zombie = {}
                     name = (function(__bsCondition, zombie)
                             if __bsCondition then
                                 return "zombie"
@@ -370,44 +473,63 @@ describe('ternary expressions', () => {
                                 return zombie.GetName()
                             end if
                         end function)(zombie.getName() = invalid, zombie)
-                `
-            );
+                end sub
+            `);
         });
 
         it('captures scope for complex consequent', () => {
-            testTranspile(
-                `name = isLoggedIn ? m.defaults.getAccount(settings.name) : "no"`,
-                `
+            testTranspile(`
+                sub main()
+                    settings = {}
+                    name = {} ? m.defaults.getAccount(settings.name) : "no"
+                end sub
+            `, `
+                sub main()
+                    settings = {}
                     name = (function(__bsCondition, m, settings)
                             if __bsCondition then
                                 return m.defaults.getAccount(settings.name)
                             else
                                 return "no"
                             end if
-                        end function)(isLoggedIn, m, settings)
-                `
-            );
+                        end function)({}, m, settings)
+                end sub
+            `);
         });
 
         it('supports scope-captured outer, and simple inner', () => {
             testTranspile(
-                `name = zombie <> invalid ? zombie.Attack(human <> invalid ? human: zombie) : "zombie"`,
                 `
-                    name = (function(__bsCondition, human, zombie)
-                            if __bsCondition then
-                                return zombie.Attack(bslib_ternary(human <> invalid, human, zombie))
-                            else
-                                return "zombie"
-                            end if
-                        end function)(zombie <> invalid, human, zombie)
+                    sub main()
+                        zombie = {}
+                        human = {}
+                        name = zombie <> invalid ? zombie.Attack(human <> invalid ? human: zombie) : "zombie"
+                    end sub
+                `,
+                `
+                    sub main()
+                        zombie = {}
+                        human = {}
+                        name = (function(__bsCondition, human, zombie)
+                                if __bsCondition then
+                                    return zombie.Attack(bslib_ternary(human <> invalid, human, zombie))
+                                else
+                                    return "zombie"
+                                end if
+                            end function)(zombie <> invalid, human, zombie)
+                    end sub
                 `
             );
         });
 
         it('uses scope capture for property access', () => {
             testTranspile(
-                `name = person <> invalid ? person.name : "John Doe"`,
                 `
+                    person = {}
+                    name = person <> invalid ? person.name : "John Doe"
+                `,
+                `
+                    person = {}
                     name = (function(__bsCondition, person)
                             if __bsCondition then
                                 return person.name
diff --git a/src/testHelpers.spec.ts b/src/testHelpers.spec.ts
index bdca57bd..5d6523b3 100644
--- a/src/testHelpers.spec.ts
+++ b/src/testHelpers.spec.ts
@@ -1,7 +1,7 @@
 import type { BscFile, BsDiagnostic } from './interfaces';
 import * as assert from 'assert';
 import chalk from 'chalk';
-import type { CompletionItem, Diagnostic } from 'vscode-languageserver';
+import type { CodeDescription, CompletionItem, Diagnostic, DiagnosticRelatedInformation, DiagnosticSeverity, DiagnosticTag, integer, Range } from 'vscode-languageserver';
 import { createSandbox } from 'sinon';
 import { expect } from 'chai';
 import type { CodeActionShorthand } from './CodeActionUtil';
@@ -41,12 +41,37 @@ function sortDiagnostics(diagnostics: BsDiagnostic[]) {
     );
 }
 
+function cloneObject<TOriginal, TTemplate>(original: TOriginal, template: TTemplate, defaultKeys: Array<keyof TOriginal>) {
+    const clone = {} as Partial<TOriginal>;
+    let keys = Object.keys(template ?? {}) as Array<keyof TOriginal>;
+    //if there were no keys provided, use some sane defaults
+    keys = keys.length > 0 ? keys : defaultKeys;
+
+    //copy only compare the specified keys from actualDiagnostic
+    for (const key of keys) {
+        clone[key] = original[key];
+    }
+    return clone;
+}
+
+interface PartialDiagnostic {
+    range?: Range;
+    severity?: DiagnosticSeverity;
+    code?: integer | string;
+    codeDescription?: Partial<CodeDescription>;
+    source?: string;
+    message?: string;
+    tags?: Partial<DiagnosticTag>[];
+    relatedInformation?: Partial<DiagnosticRelatedInformation>[];
+    data?: unknown;
+}
+
 /**
  * Ensure the DiagnosticCollection exactly contains the data from expected list.
  * @param arg - any object that contains diagnostics (such as `Program`, `Scope`, or even an array of diagnostics)
  * @param expected an array of expected diagnostics. if it's a string, assume that's a diagnostic error message
  */
-export function expectDiagnostics(arg: DiagnosticCollection, expected: Array<Partial<Diagnostic> | string | number>) {
+export function expectDiagnostics(arg: DiagnosticCollection, expected: Array<PartialDiagnostic | string | number>) {
     const diagnostics = sortDiagnostics(
         getDiagnostics(arg)
     );
@@ -58,23 +83,29 @@ export function expectDiagnostics(arg: DiagnosticCollection, expected: Array<Par
             } else if (typeof x === 'number') {
                 result = { code: x };
             }
-            return result as BsDiagnostic;
+            return result as unknown as BsDiagnostic;
         })
     );
 
     const actual = [] as BsDiagnostic[];
     for (let i = 0; i < diagnostics.length; i++) {
-        const actualDiagnostic = diagnostics[i];
-        const clone = {} as BsDiagnostic;
-        let keys = Object.keys(expectedDiagnostics[i] ?? {}) as Array<keyof BsDiagnostic>;
-        //if there were no keys provided, use some sane defaults
-        keys = keys.length > 0 ? keys : ['message', 'code', 'range', 'severity'];
-
-        //copy only compare the specified keys from actualDiagnostic
-        for (const key of keys) {
-            clone[key] = actualDiagnostic[key];
+        const expectedDiagnostic = expectedDiagnostics[i];
+        const clone = cloneObject(
+            diagnostics[i],
+            expectedDiagnostic,
+            ['message', 'code', 'range', 'severity', 'relatedInformation']
+        );
+        //deep clone relatedInformation if available
+        if (clone.relatedInformation) {
+            for (let j = 0; j < clone.relatedInformation.length; j++) {
+                clone.relatedInformation[j] = cloneObject(
+                    clone.relatedInformation[j],
+                    expectedDiagnostic.relatedInformation[j],
+                    ['location', 'message']
+                ) as any;
+            }
         }
-        actual.push(clone);
+        actual.push(clone as any);
     }
 
     expect(actual).to.eql(expectedDiagnostics);