Repository: rokucommunity/brighterscript
Pull request #1520 (2025-06-18 08:59:16)

Add `enableDiscovery` language server option
## Problem with automatic discovery

The language server aggressively tries to discover Roku projects by default.

This is fine for Roku-only developers, and developers working in small to medium repositories.

This is causing issues for developers working on large codebases, like monorepos, and developers working on a lot of non-Roku projects, where the extension goes in the way. For instance in our monorepo, the language server goes out of memory while validating the projects, repeatedly as VS attempts to restart the server.

## Adding a setting allowing to disable automatic discovery

This PR adds an `enableDiscovery` lsp option, which defaults to `true` but can be disabled.

## Uncovered issues

While adding the option, it turned out some refactoring had been done incorrectly; for instance `enableThreading` was never enabled due to incorrect workspace config preparation using unsafe casting.

Fixing the workspace config caused some unit tests to fail (some rather mysteriously).

While fixing the unit tests, I found that we have some questionable logic: why not ensure a `name` token is available in `BrsValidator`?


Patch:
diff --git a/src/LanguageServer.spec.ts b/src/LanguageServer.spec.ts
index 73a826c3..a8efda09 100644
--- a/src/LanguageServer.spec.ts
+++ b/src/LanguageServer.spec.ts
@@ -17,13 +17,14 @@ import { createVisitor, WalkMode } from './astUtils/visitors';
 import { tempDir, rootDir } from './testHelpers.spec';
 import { URI } from 'vscode-uri';
 import { BusyStatusTracker } from './BusyStatusTracker';
-import type { BscFile, WorkspaceConfigWithExtras } from '.';
+import type { BscFile } from './interfaces';
 import type { Project } from './lsp/Project';
 import { LogLevel, Logger, createLogger } from './logging';
 import { DiagnosticMessages } from './DiagnosticMessages';
 import { standardizePath } from 'roku-deploy';
 import undent from 'undent';
 import { ProjectManager } from './lsp/ProjectManager';
+import type { WorkspaceConfig } from './lsp/ProjectManager';
 
 const sinon = createSandbox();
 
@@ -167,7 +168,7 @@ describe('LanguageServer', () => {
     });
 
     describe('onDidChangeConfiguration', () => {
-        async function doTest(startingConfigs: WorkspaceConfigWithExtras[], endingConfigs: WorkspaceConfigWithExtras[]) {
+        async function doTest(startingConfigs: WorkspaceConfig[], endingConfigs: WorkspaceConfig[]) {
             (server as any)['connection'] = connection;
             server['workspaceConfigsCache'] = new Map(startingConfigs.map(x => [x.workspaceFolder, x]));
 
@@ -188,7 +189,8 @@ describe('LanguageServer', () => {
             await doTest([{
                 bsconfigPath: undefined,
                 languageServer: {
-                    enableThreading: true,
+                    enableThreading: false,
+                    enableDiscovery: true,
                     logLevel: 'info'
                 },
                 workspaceFolder: workspacePath,
@@ -196,7 +198,8 @@ describe('LanguageServer', () => {
             }], [{
                 bsconfigPath: undefined,
                 languageServer: {
-                    enableThreading: true,
+                    enableThreading: false,
+                    enableDiscovery: true,
                     logLevel: 'info'
                 },
                 workspaceFolder: workspacePath,
@@ -210,7 +213,8 @@ describe('LanguageServer', () => {
             await doTest([], [{
                 bsconfigPath: undefined,
                 languageServer: {
-                    enableThreading: true,
+                    enableThreading: false,
+                    enableDiscovery: true,
                     logLevel: 'info'
                 },
                 workspaceFolder: workspacePath,
@@ -224,7 +228,8 @@ describe('LanguageServer', () => {
             await doTest([{
                 bsconfigPath: undefined,
                 languageServer: {
-                    enableThreading: true,
+                    enableThreading: false,
+                    enableDiscovery: true,
                     logLevel: 'info'
                 },
                 workspaceFolder: workspacePath,
@@ -232,7 +237,8 @@ describe('LanguageServer', () => {
             }, {
                 bsconfigPath: undefined,
                 languageServer: {
-                    enableThreading: true,
+                    enableThreading: false,
+                    enableDiscovery: true,
                     logLevel: 'info'
                 },
                 workspaceFolder: s`${tempDir}/project2`,
@@ -240,7 +246,8 @@ describe('LanguageServer', () => {
             }], [{
                 bsconfigPath: undefined,
                 languageServer: {
-                    enableThreading: true,
+                    enableThreading: false,
+                    enableDiscovery: true,
                     logLevel: 'info'
                 },
                 workspaceFolder: workspacePath,
@@ -254,7 +261,8 @@ describe('LanguageServer', () => {
             await doTest([{
                 bsconfigPath: undefined,
                 languageServer: {
-                    enableThreading: true,
+                    enableThreading: false,
+                    enableDiscovery: true,
                     logLevel: 'trace'
                 },
                 workspaceFolder: workspacePath,
@@ -262,7 +270,8 @@ describe('LanguageServer', () => {
             }], [{
                 bsconfigPath: undefined,
                 languageServer: {
-                    enableThreading: true,
+                    enableThreading: false,
+                    enableDiscovery: true,
                     logLevel: 'info'
                 },
                 workspaceFolder: workspacePath,
@@ -649,18 +658,19 @@ describe('LanguageServer', () => {
     });
 
     describe('rebuildPathFilterer', () => {
-        let workspaceConfigs: WorkspaceConfigWithExtras[] = [];
+        let workspaceConfigs: WorkspaceConfig[] = [];
         beforeEach(() => {
             workspaceConfigs = [
                 {
                     bsconfigPath: undefined,
                     languageServer: {
-                        enableThreading: true,
+                        enableThreading: false,
+                        enableDiscovery: true,
                         logLevel: 'info'
                     },
                     workspaceFolder: workspacePath,
                     excludePatterns: []
-                } as WorkspaceConfigWithExtras
+                }
             ];
             server['connection'] = connection as any;
             sinon.stub(server as any, 'getWorkspaceConfigs').callsFake(() => Promise.resolve(workspaceConfigs));
@@ -736,7 +746,8 @@ describe('LanguageServer', () => {
             workspaceConfigs = [{
                 bsconfigPath: undefined,
                 languageServer: {
-                    enableThreading: true,
+                    enableThreading: false,
+                    enableDiscovery: true,
                     logLevel: 'info'
                 },
                 workspaceFolder: s`${tempDir}/flavor1`,
@@ -744,12 +755,13 @@ describe('LanguageServer', () => {
             }, {
                 bsconfigPath: undefined,
                 languageServer: {
-                    enableThreading: true,
+                    enableThreading: false,
+                    enableDiscovery: true,
                     logLevel: 'info'
                 },
                 workspaceFolder: s`${tempDir}/flavor2`,
                 excludePatterns: []
-            }] as WorkspaceConfigWithExtras[];
+            }];
             fsExtra.outputFileSync(s`${workspaceConfigs[0].workspaceFolder}/.gitignore`, undent`
                 dist/
             `);
diff --git a/src/LanguageServer.ts b/src/LanguageServer.ts
index 9ccb6172..f6041a18 100644
--- a/src/LanguageServer.ts
+++ b/src/LanguageServer.ts
@@ -61,6 +61,10 @@ export class LanguageServer {
      * The default threading setting for the language server. Can be overridden by per-workspace settings
      */
     public static enableThreadingDefault = true;
+    /**
+     * The default project discovery setting for the language server. Can be overridden by per-workspace settings
+     */
+    public static enableDiscoveryDefault = true;
     /**
      * The language server protocol connection, used to send and receive all requests and responses
      */
@@ -417,7 +421,7 @@ export class LanguageServer {
      * Get a list of workspaces, and their configurations.
      * Get only the settings for the workspace that are relevant to the language server. We do this so we can cache this object for use in change detection in the future.
      */
-    private async getWorkspaceConfigs(): Promise<WorkspaceConfigWithExtras[]> {
+    private async getWorkspaceConfigs(): Promise<WorkspaceConfig[]> {
         //get all workspace folders (we'll use these to get settings)
         let workspaces = await Promise.all(
             (await this.connection.workspace.getWorkspaceFolders() ?? []).map(async (x) => {
@@ -429,16 +433,17 @@ export class LanguageServer {
                     bsconfigPath: brightscriptConfig.configFile,
                     languageServer: {
                         enableThreading: brightscriptConfig.languageServer?.enableThreading ?? LanguageServer.enableThreadingDefault,
+                        enableDiscovery: brightscriptConfig.languageServer?.enableDiscovery ?? LanguageServer.enableDiscoveryDefault,
                         logLevel: brightscriptConfig?.languageServer?.logLevel
                     }
 
-                } as WorkspaceConfigWithExtras;
+                };
             })
         );
         return workspaces;
     }
 
-    private workspaceConfigsCache = new Map<string, WorkspaceConfigWithExtras>();
+    private workspaceConfigsCache = new Map<string, WorkspaceConfig>();
 
     @AddStackToErrorMessage
     public async onDidChangeConfiguration(args: DidChangeConfigurationParams) {
@@ -795,6 +800,7 @@ interface BrightScriptClientConfiguration {
     configFile: string;
     languageServer: {
         enableThreading: boolean;
+        enableDiscovery: boolean;
         logLevel: LogLevel | string;
     };
 }
@@ -805,11 +811,3 @@ function logAndIgnoreError(error: Error) {
     }
     console.error(error);
 }
-
-export type WorkspaceConfigWithExtras = WorkspaceConfig & {
-    bsconfigPath: string;
-    languageServer: {
-        enableThreading: boolean;
-        logLevel: LogLevel | string | undefined;
-    };
-};
diff --git a/src/bscPlugin/validation/BrsFileValidator.ts b/src/bscPlugin/validation/BrsFileValidator.ts
index 9508b12b..e65841f9 100644
--- a/src/bscPlugin/validation/BrsFileValidator.ts
+++ b/src/bscPlugin/validation/BrsFileValidator.ts
@@ -52,17 +52,23 @@ export class BrsFileValidator {
                 this.validateEnumDeclaration(node);
 
                 //register this enum declaration
-                node.parent.getSymbolTable()?.addSymbol(node.tokens.name.text, node.tokens.name.range, DynamicType.instance);
+                if (node.tokens.name) {
+                    node.parent.getSymbolTable()?.addSymbol(node.tokens.name.text, node.tokens.name.range, DynamicType.instance);
+                }
             },
             ClassStatement: (node) => {
                 this.validateDeclarationLocations(node, 'class', () => util.createBoundingRange(node.classKeyword, node.name));
 
                 //register this class
-                node.parent.getSymbolTable()?.addSymbol(node.name.text, node.name.range, DynamicType.instance);
+                if (node.name) {
+                    node.parent.getSymbolTable()?.addSymbol(node.name.text, node.name.range, DynamicType.instance);
+                }
             },
             AssignmentStatement: (node) => {
                 //register this variable
-                node.parent.getSymbolTable()?.addSymbol(node.name.text, node.name.range, DynamicType.instance);
+                if (node.name) {
+                    node.parent.getSymbolTable()?.addSymbol(node.name.text, node.name.range, DynamicType.instance);
+                }
             },
             DottedSetStatement: (node) => {
                 this.validateNoOptionalChainingInVarSet(node, [node.obj]);
@@ -77,11 +83,13 @@ export class BrsFileValidator {
             NamespaceStatement: (node) => {
                 this.validateDeclarationLocations(node, 'namespace', () => util.createBoundingRange(node.keyword, node.nameExpression));
 
-                node.parent.getSymbolTable().addSymbol(
-                    node.name.split('.')[0],
-                    node.nameExpression.range,
-                    DynamicType.instance
-                );
+                if (node.name) {
+                    node.parent.getSymbolTable().addSymbol(
+                        node.name.split('.')[0],
+                        node.nameExpression.range,
+                        DynamicType.instance
+                    );
+                }
             },
             FunctionStatement: (node) => {
                 this.validateDeclarationLocations(node, 'function', () => util.createBoundingRange(node.func.functionType, node.name));
@@ -101,11 +109,13 @@ export class BrsFileValidator {
                     const funcType = node.func.getFunctionType();
                     funcType.setName(transpiledNamespaceFunctionName);
 
-                    this.event.file.parser.ast.symbolTable.addSymbol(
-                        transpiledNamespaceFunctionName,
-                        node.name.range,
-                        funcType
-                    );
+                    if (node.name) {
+                        this.event.file.parser.ast.symbolTable.addSymbol(
+                            transpiledNamespaceFunctionName,
+                            node.name.range,
+                            funcType
+                        );
+                    }
                 }
             },
             FunctionExpression: (node) => {
@@ -115,17 +125,23 @@ export class BrsFileValidator {
                 this.validateFunctionParameterCount(node);
             },
             FunctionParameterExpression: (node) => {
-                const paramName = node.name?.text;
-                const symbolTable = node.getSymbolTable();
-                symbolTable?.addSymbol(paramName, node.name.range, node.type);
+                if (node.name) {
+                    const paramName = node.name.text;
+                    const symbolTable = node.getSymbolTable();
+                    symbolTable?.addSymbol(paramName, node.name.range, node.type);
+                }
             },
             InterfaceStatement: (node) => {
                 this.validateDeclarationLocations(node, 'interface', () => util.createBoundingRange(node.tokens.interface, node.tokens.name));
-                node.parent?.getSymbolTable()?.addSymbol(node.tokens.name.text, node.tokens.name.range, new InterfaceType(new Map()));
+                if (node.tokens.name) {
+                    node.parent?.getSymbolTable()?.addSymbol(node.tokens.name.text, node.tokens.name.range, new InterfaceType(new Map()));
+                }
             },
             ConstStatement: (node) => {
                 this.validateDeclarationLocations(node, 'const', () => util.createBoundingRange(node.tokens.const, node.tokens.name));
-                node.parent.getSymbolTable().addSymbol(node.tokens.name.text, node.tokens.name.range, DynamicType.instance);
+                if (node.tokens.name) {
+                    node.parent.getSymbolTable().addSymbol(node.tokens.name.text, node.tokens.name.range, DynamicType.instance);
+                }
             },
             CatchStatement: (node) => {
                 node.parent.getSymbolTable().addSymbol(node.exceptionVariable.text, node.exceptionVariable.range, DynamicType.instance);
diff --git a/src/lsp/ProjectManager.spec.ts b/src/lsp/ProjectManager.spec.ts
index 93a55e56..a38bfa4f 100644
--- a/src/lsp/ProjectManager.spec.ts
+++ b/src/lsp/ProjectManager.spec.ts
@@ -1,6 +1,6 @@
 import { expect } from 'chai';
 import { ProjectManager } from './ProjectManager';
-import { tempDir, rootDir, expectZeroDiagnostics, expectDiagnostics, expectCompletionsIncludes } from '../testHelpers.spec';
+import { tempDir, rootDir, expectZeroDiagnostics, expectDiagnostics, expectCompletionsIncludes, workspaceSettings } from '../testHelpers.spec';
 import * as fsExtra from 'fs-extra';
 import util, { standardizePath as s } from '../util';
 import type { SinonStub } from 'sinon';
@@ -93,9 +93,7 @@ describe('ProjectManager', () => {
 
     describe('validation tracking', () => {
         it('tracks validation state', async () => {
-            await manager.syncProjects([{
-                workspaceFolder: rootDir
-            }]);
+            await manager.syncProjects([workspaceSettings]);
             const project = manager.projects[0] as Project;
 
             //force validation to take a while
@@ -127,18 +125,14 @@ describe('ProjectManager', () => {
 
         it('finds bsconfig in a folder', async () => {
             fsExtra.outputFileSync(`${rootDir}/bsconfig.json`, '');
-            await manager.syncProjects([{
-                workspaceFolder: rootDir
-            }]);
+            await manager.syncProjects([workspaceSettings]);
             expect(manager.projects[0].projectPath).to.eql(s`${rootDir}`);
         });
 
         it('finds bsconfig at root and also in subfolder', async () => {
             fsExtra.outputFileSync(`${rootDir}/bsconfig.json`, '');
             fsExtra.outputFileSync(`${rootDir}/subdir/bsconfig.json`, '');
-            await manager.syncProjects([{
-                workspaceFolder: rootDir
-            }]);
+            await manager.syncProjects([workspaceSettings]);
             expect(
                 manager.projects.map(x => x.projectPath).sort()
             ).to.eql([
@@ -151,7 +145,7 @@ describe('ProjectManager', () => {
             fsExtra.outputFileSync(`${rootDir}/bsconfig.json`, '');
             fsExtra.outputFileSync(`${rootDir}/subdir/bsconfig.json`, '');
             await manager.syncProjects([{
-                workspaceFolder: rootDir,
+                ...workspaceSettings,
                 excludePatterns: ['**/subdir/**/*']
             }]);
             expect(
@@ -163,8 +157,23 @@ describe('ProjectManager', () => {
 
         it('uses rootDir when manifest found but no brightscript file', async () => {
             fsExtra.outputFileSync(`${rootDir}/subdir/manifest`, '');
+            await manager.syncProjects([workspaceSettings]);
+            expect(
+                manager.projects.map(x => x.projectPath)
+            ).to.eql([
+                s`${rootDir}`
+            ]);
+        });
+
+        it('returns root folder when automatic discovery is disabled', async () => {
+            fsExtra.outputFileSync(`${rootDir}/project1/bsconfig.json`, '');
+            fsExtra.outputFileSync(`${rootDir}/project2/bsconfig.json`, '');
             await manager.syncProjects([{
-                workspaceFolder: rootDir
+                ...workspaceSettings,
+                languageServer: {
+                    ...workspaceSettings.languageServer,
+                    enableDiscovery: false
+                }
             }]);
             expect(
                 manager.projects.map(x => x.projectPath)
@@ -201,9 +210,7 @@ describe('ProjectManager', () => {
                 end sub
             `);
             fsExtra.outputFileSync(`${rootDir}/manifest`, '');
-            await manager.syncProjects([{
-                workspaceFolder: rootDir
-            }]);
+            await manager.syncProjects([workspaceSettings]);
             expectDiagnostics(await onNextDiagnostics(), [
                 DiagnosticMessages.cannotFindName('nameNotDefined').message,
                 'Test diagnostic'
@@ -213,9 +220,7 @@ describe('ProjectManager', () => {
         it('uses subdir when manifest and brightscript file found', async () => {
             fsExtra.outputFileSync(`${rootDir}/subdir/manifest`, '');
             fsExtra.outputFileSync(`${rootDir}/subdir/source/main.brs`, '');
-            await manager.syncProjects([{
-                workspaceFolder: rootDir
-            }]);
+            await manager.syncProjects([workspaceSettings]);
             expect(
                 manager.projects.map(x => x.projectPath)
             ).to.eql([
@@ -226,9 +231,7 @@ describe('ProjectManager', () => {
         it('removes stale projects', async () => {
             fsExtra.outputFileSync(`${rootDir}/subdir1/bsconfig.json`, '');
             fsExtra.outputFileSync(`${rootDir}/subdir2/bsconfig.json`, '');
-            await manager.syncProjects([{
-                workspaceFolder: rootDir
-            }]);
+            await manager.syncProjects([workspaceSettings]);
             expect(
                 manager.projects.map(x => x.projectPath).sort()
             ).to.eql([
@@ -237,9 +240,7 @@ describe('ProjectManager', () => {
             ]);
             fsExtra.removeSync(`${rootDir}/subdir1/bsconfig.json`);
 
-            await manager.syncProjects([{
-                workspaceFolder: rootDir
-            }]);
+            await manager.syncProjects([workspaceSettings]);
             expect(
                 manager.projects.map(x => x.projectPath).sort()
             ).to.eql([
@@ -250,9 +251,7 @@ describe('ProjectManager', () => {
         it('keeps existing projects on subsequent sync calls', async () => {
             fsExtra.outputFileSync(`${rootDir}/subdir1/bsconfig.json`, '');
             fsExtra.outputFileSync(`${rootDir}/subdir2/bsconfig.json`, '');
-            await manager.syncProjects([{
-                workspaceFolder: rootDir
-            }]);
+            await manager.syncProjects([workspaceSettings]);
             expect(
                 manager.projects.map(x => x.projectPath).sort()
             ).to.eql([
@@ -260,9 +259,7 @@ describe('ProjectManager', () => {
                 s`${rootDir}/subdir2`
             ]);
 
-            await manager.syncProjects([{
-                workspaceFolder: rootDir
-            }]);
+            await manager.syncProjects([workspaceSettings]);
             expect(
                 manager.projects.map(x => x.projectPath).sort()
             ).to.eql([
@@ -275,9 +272,7 @@ describe('ProjectManager', () => {
     describe('getCompletions', () => {
         it('works for quick file changes', async () => {
             //set up the project
-            await manager.syncProjects([{
-                workspaceFolder: rootDir
-            }]);
+            await manager.syncProjects([workspaceSettings]);
 
             //add the namespace first
             await setFile(s`${rootDir}/source/alpha.bs`, `
@@ -334,9 +329,7 @@ describe('ProjectManager', () => {
             fsExtra.outputJsonSync(`${rootDir}/project1/bsconfig.json`, {
                 rootDir: rootDir
             });
-            await manager.syncProjects([{
-                workspaceFolder: rootDir
-            }]);
+            await manager.syncProjects([workspaceSettings]);
 
             sinon.stub(manager.projects[0], 'applyFileChanges').returns(Promise.resolve([
                 //return an undefined item, which used to cause a specific crash
@@ -375,9 +368,7 @@ describe('ProjectManager', () => {
                 ]
             });
 
-            await manager.syncProjects([{
-                workspaceFolder: rootDir
-            }]);
+            await manager.syncProjects([workspaceSettings]);
 
             let deferred1 = new Deferred();
             let deferred2 = new Deferred();
@@ -431,9 +422,7 @@ describe('ProjectManager', () => {
                 ]
             });
 
-            await manager.syncProjects([{
-                workspaceFolder: rootDir
-            }]);
+            await manager.syncProjects([workspaceSettings]);
 
             const stub = sinon.stub(manager as any, 'handleFileChange').callThrough();
 
@@ -478,9 +467,7 @@ describe('ProjectManager', () => {
                 files: ['source/**/*']
             });
 
-            await manager.syncProjects([{
-                workspaceFolder: rootDir
-            }]);
+            await manager.syncProjects([workspaceSettings]);
 
             const stub = sinon.stub(manager['projects'][0], 'applyFileChanges').callThrough();
 
@@ -505,9 +492,7 @@ describe('ProjectManager', () => {
         it('does not create a standalone project for files that exist in a known project', async () => {
             fsExtra.outputFileSync(s`${rootDir}/source/main.brs`, `sub main() : end sub`);
 
-            await manager.syncProjects([{
-                workspaceFolder: rootDir
-            }]);
+            await manager.syncProjects([workspaceSettings]);
 
             await onNextDiagnostics();
 
@@ -522,9 +507,7 @@ describe('ProjectManager', () => {
         });
 
         it('converts a missing file to a delete', async () => {
-            await manager.syncProjects([{
-                workspaceFolder: rootDir
-            }]);
+            await manager.syncProjects([workspaceSettings]);
             await onNextDiagnostics();
 
             let applyFileChangesDeferred = new Deferred<DocumentActionWithStatus[]>();
@@ -568,9 +551,7 @@ describe('ProjectManager', () => {
         it('properly syncs changes', async () => {
             fsExtra.outputFileSync(`${rootDir}/source/lib1.brs`, `sub test1():print "alpha":end sub`);
             fsExtra.outputFileSync(`${rootDir}/source/lib2.brs`, `sub test2():print "beta":end sub`);
-            await manager.syncProjects([{
-                workspaceFolder: rootDir
-            }]);
+            await manager.syncProjects([workspaceSettings]);
             expectZeroDiagnostics(await onNextDiagnostics());
 
             await manager.handleFileChanges([
@@ -594,9 +575,7 @@ describe('ProjectManager', () => {
         it('adds all new files in a folder', async () => {
             fsExtra.outputFileSync(`${rootDir}/source/main.brs`, `sub main():print "main":end sub`);
 
-            await manager.syncProjects([{
-                workspaceFolder: rootDir
-            }]);
+            await manager.syncProjects([workspaceSettings]);
             expectZeroDiagnostics(await onNextDiagnostics());
 
             //add a few files to a folder, then register that folder as an "add"
@@ -622,9 +601,7 @@ describe('ProjectManager', () => {
             fsExtra.outputFileSync(`${rootDir}/source/libs/alpha/charlie/delta.brs`, `sub delta():print two:end sub`);
             fsExtra.outputFileSync(`${rootDir}/source/libs/echo/foxtrot.brs`, `sub foxtrot():print three:end sub`);
 
-            await manager.syncProjects([{
-                workspaceFolder: rootDir
-            }]);
+            await manager.syncProjects([workspaceSettings]);
 
             expectDiagnostics(await onNextDiagnostics(), [
                 DiagnosticMessages.cannotFindName('one').message,
@@ -650,8 +627,11 @@ describe('ProjectManager', () => {
 
         it('spawns a worker thread when threading is enabled', async () => {
             await manager.syncProjects([{
-                workspaceFolder: rootDir,
-                enableThreading: true
+                ...workspaceSettings,
+                languageServer: {
+                    ...workspaceSettings.languageServer,
+                    enableThreading: true
+                }
             }]);
             expect(manager.projects[0]).instanceof(WorkerThreadProject);
         });
@@ -659,9 +639,7 @@ describe('ProjectManager', () => {
 
     describe('getProject', () => {
         it('uses .projectPath if param is not a string', async () => {
-            await manager.syncProjects([{
-                workspaceFolder: rootDir
-            }]);
+            await manager.syncProjects([workspaceSettings]);
             expect(
                 manager['getProject']({
                     projectPath: rootDir
@@ -674,9 +652,7 @@ describe('ProjectManager', () => {
 
     describe('createAndActivateProject', () => {
         it('skips creating project if we already have it', async () => {
-            await manager.syncProjects([{
-                workspaceFolder: rootDir
-            }]);
+            await manager.syncProjects([workspaceSettings]);
 
             await manager['createAndActivateProject']({
                 projectPath: rootDir
@@ -715,9 +691,7 @@ describe('ProjectManager', () => {
     describe('removeProject', () => {
         it('handles undefined', async () => {
             manager['removeProject'](undefined);
-            await manager.syncProjects([{
-                workspaceFolder: rootDir
-            }]);
+            await manager.syncProjects([workspaceSettings]);
             manager['removeProject'](undefined);
         });
 
@@ -812,7 +786,7 @@ describe('ProjectManager', () => {
         const host = '127.0.0.1';
 
         //write a small brighterscript plugin to allow this test to communicate with the thread
-        fsExtra.outputFileSync(`${rootDir}/plugin.js`, `
+        fsExtra.outputFileSync(`${rootDir}/pluginsocket.js`, `
             ${Plugin.toString()};
             exports.default = function() {
                 return new Plugin(${port}, "${host}");
@@ -821,14 +795,17 @@ describe('ProjectManager', () => {
         //write a bsconfig that will load this plugin
         fsExtra.outputJsonSync(`${rootDir}/bsconfig.json`, {
             plugins: [
-                `${rootDir}/plugin.js`
+                `${rootDir}/pluginsocket.js`
             ]
         });
 
         //wait for the projects to finish syncing/loading
         await manager.syncProjects([{
-            workspaceFolder: rootDir,
-            enableThreading: true
+            ...workspaceSettings,
+            languageServer: {
+                ...workspaceSettings.languageServer,
+                enableThreading: true
+            }
         }]);
 
         //establish the connection with the plugin
@@ -871,10 +848,7 @@ describe('ProjectManager', () => {
         });
 
         //wait for the projects to finish syncing/loading
-        await manager.syncProjects([{
-            workspaceFolder: rootDir,
-            enableThreading: false
-        }]);
+        await manager.syncProjects([workspaceSettings]);
 
         const stub = sinon.stub(manager as any, 'reloadProject').callThrough();
 
diff --git a/src/lsp/ProjectManager.ts b/src/lsp/ProjectManager.ts
index 72f42ff1..8900804e 100644
--- a/src/lsp/ProjectManager.ts
+++ b/src/lsp/ProjectManager.ts
@@ -14,7 +14,7 @@ import type { FileChange, MaybePromise } from '../interfaces';
 import { BusyStatusTracker } from '../BusyStatusTracker';
 import * as fastGlob from 'fast-glob';
 import { PathCollection, PathFilterer } from './PathFilterer';
-import type { Logger } from '../logging';
+import type { Logger, LogLevel } from '../logging';
 import { createLogger } from '../logging';
 import { Cache } from '../Cache';
 import { ActionQueue } from './ActionQueue';
@@ -284,7 +284,7 @@ export class ProjectManager {
                         projectPath: s`${projectPath}`,
                         workspaceFolder: s`${workspaceConfig.workspaceFolder}`,
                         excludePatterns: workspaceConfig.excludePatterns,
-                        enableThreading: workspaceConfig.enableThreading
+                        enableThreading: workspaceConfig.languageServer.enableThreading
                     }));
                 })
             )).flat(1);
@@ -646,6 +646,11 @@ export class ProjectManager {
      * If none are found, then the workspaceFolder itself is treated as a project
      */
     private async getProjectPaths(workspaceConfig: WorkspaceConfig) {
+        //automatic discovery disabled?
+        if (!workspaceConfig.languageServer.enableDiscovery) {
+            return [workspaceConfig.workspaceFolder];
+        }
+
         //get the list of exclude patterns, negate them so they actually work like excludes), and coerce to forward slashes since that's what fast-glob expects
         const excludePatterns = (workspaceConfig.excludePatterns ?? []).map(x => s`!${x}`.replace(/[\\/]+/g, '/'));
 
@@ -868,9 +873,22 @@ export interface WorkspaceConfig {
      */
     bsconfigPath?: string;
     /**
-     * Should the projects in this workspace be run in their own dedicated worker threads, or all run on the main thread
+     * Language server configuration options
      */
-    enableThreading?: boolean;
+    languageServer: {
+        /**
+         * Should the projects in this workspace be run in their own dedicated worker threads, or all run on the main thread
+         */
+        enableThreading: boolean;
+        /**
+         * Should the language server automatically discover projects in this workspace?
+         */
+        enableDiscovery: boolean;
+        /**
+         * The log level to use for this workspace
+         */
+        logLevel?: LogLevel | string;
+    };
 }
 
 interface StandaloneProject extends LspProject {
diff --git a/src/util.ts b/src/util.ts
index def03f51..e0238786 100644
--- a/src/util.ts
+++ b/src/util.ts
@@ -905,7 +905,9 @@ export class Util {
      * If the two items both start on the same line
      */
     public sameStartLine(first: { range: Range }, second: { range: Range }) {
-        if (first && second && first.range.start.line === second.range.start.line) {
+        if (first && second && (first.range !== undefined) && (second.range !== undefined) &&
+            first.range.start.line === second.range.start.line
+        ) {
             return true;
         } else {
             return false;


Test patch:
diff --git a/src/testHelpers.spec.ts b/src/testHelpers.spec.ts
index 30c12703..bf5183d9 100644
--- a/src/testHelpers.spec.ts
+++ b/src/testHelpers.spec.ts
@@ -13,11 +13,19 @@ import type { CodeWithSourceMap } from 'source-map';
 import { getDiagnosticLine } from './diagnosticUtils';
 import { firstBy } from 'thenby';
 import undent from 'undent';
+import type { WorkspaceConfig } from './lsp/ProjectManager';
 
 export const cwd = s`${__dirname}/../`;
 export const tempDir = s`${__dirname}/../.tmp`;
 export const rootDir = s`${tempDir}/rootDir`;
 export const stagingDir = s`${tempDir}/stagingDir`;
+export const workspaceSettings: WorkspaceConfig = {
+    workspaceFolder: rootDir,
+    languageServer: {
+        enableThreading: false,
+        enableDiscovery: true
+    }
+};
 
 export const trim = undent;
 const sinon = createSandbox();