• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

rokucommunity / brighterscript / #15695

29 Apr 2026 02:55PM UTC coverage: 89.125% (+0.03%) from 89.093%
#15695

push

web-flow
Limit project activation concurrency (#1627)

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Bronley Plumb <bronley@gmail.com>
Co-authored-by: Philippe Elsass <philippe.elsass@gmail.com>
Co-authored-by: Copilot <copilot@github.com>

8384 of 9910 branches covered (84.6%)

Branch coverage included in aggregate %.

40 of 41 new or added lines in 3 files covered. (97.56%)

1 existing line in 1 file now uncovered.

10646 of 11442 relevant lines covered (93.04%)

2044.81 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

85.22
/src/lsp/ProjectManager.ts
1
import { standardizePath as s, util } from '../util';
1✔
2
import { rokuDeploy } from 'roku-deploy';
1✔
3
import * as path from 'path';
1✔
4
import * as EventEmitter from 'eventemitter3';
1✔
5
import type { LspDiagnostic, LspProject, ProjectConfig } from './LspProject';
6
import { Project } from './Project';
1✔
7
import { WorkerThreadProject } from './worker/WorkerThreadProject';
1✔
8
import { FileChangeType } from 'vscode-languageserver-protocol';
1✔
9
import type { Hover, Position, Range, Location, SignatureHelp, DocumentSymbol, SymbolInformation, WorkspaceSymbol, CompletionList, CancellationToken, SelectionRange } from 'vscode-languageserver-protocol';
10
import { Deferred } from '../deferred';
1✔
11
import type { DocumentActionWithStatus, FlushEvent } from './DocumentManager';
12
import { DocumentManager } from './DocumentManager';
1✔
13
import type { FileChange, MaybePromise } from '../interfaces';
14
import { BusyStatusTracker } from '../BusyStatusTracker';
1✔
15
import * as fastGlob from 'fast-glob';
1✔
16
import { PathCollection, PathFilterer } from './PathFilterer';
1✔
17
import type { Logger, LogLevel } from '../logging';
18
import { createLogger } from '../logging';
1✔
19
import { Cache } from '../Cache';
1✔
20
import { ActionQueue } from './ActionQueue';
1✔
21
import * as fsExtra from 'fs-extra';
1✔
22
import type { BrightScriptProjectConfiguration } from '../LanguageServer';
23

24
const FileChangeTypeLookup = Object.entries(FileChangeType).reduce((acc, [key, value]) => {
1✔
25
    acc[value] = key;
3✔
26
    acc[key] = value;
3✔
27
    return acc;
3✔
28
}, {});
29

30
/**
31
 * Manages all brighterscript projects for the language server
32
 */
33
export class ProjectManager {
1✔
34
    constructor(options?: {
35
        pathFilterer: PathFilterer;
36
        logger?: Logger;
37
    }) {
38
        this.logger = options?.logger ?? createLogger();
141!
39
        this.pathFilterer = options?.pathFilterer ?? new PathFilterer({ logger: options?.logger });
141!
40
        this.documentManager = new DocumentManager({
141✔
41
            delay: ProjectManager.documentManagerDelay,
42
            flushHandler: (event) => {
43
                return this.flushDocumentChanges(event).catch(e => console.error(e));
40✔
44
            }
45
        });
46

47
        this.on('validate-begin', (event) => {
141✔
48
            this.busyStatusTracker.beginScopedRun(event.project, `validate-project`);
132✔
49
        });
50
        this.on('validate-end', (event) => {
141✔
51
            void this.busyStatusTracker.endScopedRun(event.project, `validate-project`);
129✔
52
        });
53
    }
54

55
    private pathFilterer: PathFilterer;
56

57
    private logger: Logger;
58

59
    /**
60
     * Collection of all projects
61
     */
62
    public projects: LspProject[] = [];
141✔
63

64
    /**
65
     * Collection of standalone projects. These are projects that are not part of a workspace, but are instead single files.
66
     * All of these are also present in the `projects` collection.
67
     */
68
    private standaloneProjects = new Map<string, StandaloneProject>();
141✔
69

70
    private documentManager: DocumentManager;
71
    public static documentManagerDelay = 150;
1✔
72

73
    /**
74
     * Maximum number of projects to activate or validate concurrently during syncProjects.
75
     * Limits CPU spikes when many projects are discovered (e.g. in large monorepos).
76
     */
77
    public projectActivationConcurrencyLimit = 3;
141✔
78

79
    public busyStatusTracker = new BusyStatusTracker<LspProject>();
141✔
80

81
    /**
82
     * Cache for PathCollection instances per project. Avoids recreating PathCollection
83
     * on every document flush, which is wasteful since file patterns only change when a project is reloaded.
84
     */
85
    private projectFiltererCache = new WeakMap<LspProject, PathCollection>();
141✔
86

87
    /**
88
     * Get or create a cached PathCollection for the given project.
89
     * The filterer is invalidated when the project is removed and garbage collected.
90
     */
91
    private getProjectFilterer(project: LspProject): PathCollection {
92
        let filterer = this.projectFiltererCache.get(project);
50✔
93
        if (!filterer) {
50✔
94
            filterer = new PathCollection({
38✔
95
                rootDir: project.rootDir,
96
                globs: project.filePatterns
97
            });
98
            this.projectFiltererCache.set(project, filterer);
38✔
99
        }
100
        return filterer;
50✔
101
    }
102

103
    /**
104
     * Apply all of the queued document changes. This should only be called as a result of the documentManager flushing changes, and never called manually
105
     * @param event the document changes that have occurred since the last time we applied
106
     */
107
    @TrackBusyStatus
108
    private async flushDocumentChanges(event: FlushEvent) {
1✔
109

110
        this.logger.info(`flushDocumentChanges`, event?.actions?.map(x => ({
78!
111
            type: x.type,
112
            srcPath: x.srcPath,
113
            allowStandaloneProject: x.allowStandaloneProject
114
        })));
115

116
        //ensure that we're fully initialized before proceeding
117
        await this.onInitialized();
43✔
118

119
        const actions = [...event.actions] as DocumentActionWithStatus[];
43✔
120

121
        let idSequence = 0;
43✔
122
        //add an ID to every action (so we can track which actions were handled by which projects)
123
        for (const action of actions) {
43✔
124
            action.id = idSequence++;
78✔
125
        }
126

127
        //apply all of the document actions to each project in parallel
128
        const responses = await Promise.all(this.projects.map(async (project) => {
43✔
129
            //wait for this project to finish activating
130
            await project.whenActivated();
50✔
131

132
            const filterer = this.getProjectFilterer(project);
50✔
133
            // only include files that are applicable to this specific project (still allow deletes to flow through since they're cheap)
134
            const projectActions = actions.filter(action => {
50✔
135
                return (
90✔
136
                    //if this is a delete, just pass it through because they're cheap to apply
137
                    action.type === 'delete' ||
179✔
138
                    //if this is a set, only pass it through if it's a file that this project cares about
139
                    filterer.isMatch(action.srcPath)
140
                );
141
            });
142
            if (projectActions.length > 0) {
50✔
143
                const responseActions = await project.applyFileChanges(projectActions);
39✔
144
                return responseActions.map(x => ({
72✔
145
                    project: project,
146
                    action: x
147
                }));
148
            }
149
        }));
150

151
        //create standalone projects for any files not handled by any project
152
        const flatResponses = responses.flat();
43✔
153
        for (const action of actions) {
43✔
154
            //skip this action if it doesn't support standalone projects
155
            if (!action.allowStandaloneProject || action.type !== 'set') {
78✔
156
                continue;
27✔
157
            }
158

159
            //a list of responses that handled this action
160
            const handledResponses = flatResponses.filter(x => x?.action?.id === action.id && x?.action?.status === 'accepted');
144!
161

162
            //remove any standalone project created for this file since it was handled by a normal project
163
            const normalProjectsThatHandledThisFile = handledResponses.filter(x => !x.project.isStandaloneProject);
51✔
164
            if (normalProjectsThatHandledThisFile.length > 0) {
51✔
165
                //if there's a standalone project for this file, delete it
166
                if (this.getStandaloneProject(action.srcPath, false)) {
42✔
167
                    this.logger.debug(
1✔
168
                        `flushDocumentChanges: removing standalone project because the following normal projects handled the file: '${action.srcPath}', projects:`,
169
                        normalProjectsThatHandledThisFile.map(x => util.getProjectLogName(x.project))
1✔
170
                    );
171
                    this.removeStandaloneProject(action.srcPath);
1✔
172
                }
173

174
                // create a standalone project if this action was handled by zero normal projects.
175
                //(safe to call even if there's already a standalone project, won't create dupes)
176
            } else {
177
                //TODO only create standalone projects for files we understand (brightscript, brighterscript, scenegraph xml, etc)
178
                await this.createStandaloneProject(action.srcPath);
9✔
179
            }
180
        }
181
        this.logger.info('flushDocumentChanges complete', actions.map(x => ({
78✔
182
            type: x.type,
183
            srcPath: x.srcPath,
184
            allowStandaloneProject: x.allowStandaloneProject
185
        })));
186
    }
187

188
    /**
189
     * Get a standalone project for a given file path
190
     */
191
    private getStandaloneProject(srcPath: string, standardizePath = true) {
×
192
        return this.standaloneProjects.get(
53✔
193
            standardizePath ? util.standardizePath(srcPath) : srcPath
53!
194
        );
195
    }
196

197
    /**
198
     * Create a project that validates a single file. This is useful for getting language support for files that don't belong to a project
199
     */
200
    private async createStandaloneProject(srcPath: string) {
201
        srcPath = util.standardizePath(srcPath);
9✔
202

203
        //if we already have a standalone project with this path, do nothing because it already exists
204
        if (this.getStandaloneProject(srcPath, false)) {
9✔
205
            this.logger.log('createStandaloneProject skipping because we already have one for this path');
4✔
206
            return;
4✔
207
        }
208

209
        this.logger.log(`Creating standalone project for '${srcPath}'`);
5✔
210

211
        const projectNumber = ProjectManager.projectNumberSequence++;
5✔
212
        const rootDir = path.join(__dirname, `standalone-project-${projectNumber}`);
5✔
213
        const projectConfig: ProjectConfig = {
5✔
214
            //these folders don't matter for standalone projects
215
            workspaceFolder: rootDir,
216
            projectDir: rootDir,
217
            //there's no bsconfig.json for standalone projects, so projectKey is the same as the dir
218
            projectKey: rootDir,
219
            bsconfigPath: undefined,
220
            enableThreading: false,
221
            projectNumber: projectNumber,
222
            files: [{
223
                src: srcPath,
224
                dest: 'source/standalone.brs'
225
            }]
226
        };
227

228
        const project = this.constructProject(projectConfig) as StandaloneProject;
5✔
229
        project.srcPath = srcPath;
5✔
230
        project.isStandaloneProject = true;
5✔
231

232
        this.standaloneProjects.set(srcPath, project);
5✔
233
        await this.activateProject(project, projectConfig);
5✔
234
        void project.validate();
5✔
235
    }
236

237
    private removeStandaloneProject(srcPath: string) {
238
        srcPath = util.standardizePath(srcPath);
2✔
239
        const project = this.getStandaloneProject(srcPath, false);
2✔
240
        if (project) {
2!
241
            if (project.srcPath === srcPath) {
2!
242
                this.logger.debug(`Removing standalone project for file '${srcPath}'`);
2✔
243
                this.removeProject(project);
2✔
244
                this.standaloneProjects.delete(srcPath);
2✔
245
            }
246
        }
247
    }
248

249
    /**
250
     * A promise that's set when a sync starts, and resolved when the sync is complete
251
     */
252
    private syncPromise: Promise<void> | undefined;
253
    private firstSync = new Deferred();
141✔
254

255
    /**
256
     * Monotonically increasing counter used to detect stale sync cycles.
257
     * When a new `syncProjects` call arrives, any in-progress activation or validation
258
     * from a previous cycle will see a mismatched generation and bail out.
259
     */
260
    private syncGeneration = 0;
141✔
261

262
    /**
263
     * Get a promise that resolves when this manager is finished initializing
264
     */
265
    public onInitialized() {
266
        return Promise.allSettled([
145✔
267
            //wait for the first sync to finish
268
            this.firstSync.promise,
269
            //make sure we're not in the middle of a sync
270
            this.syncPromise,
271
            //make sure all projects are activated
272
            ...this.projects.map(x => x.whenActivated())
164✔
273
        ]);
274
    }
275
    /**
276
     * Get a promise that resolves when the project manager is idle (no pending work)
277
     */
278
    public async onIdle() {
279
        await this.onInitialized();
33✔
280

281
        //There are race conditions where the fileChangesQueue will become idle, but that causes the documentManager
282
        //to start a new flush. So we must keep waiting until everything is idle
283
        while (!this.documentManager.isIdle || !this.fileChangesQueue.isIdle) {
33✔
284
            this.logger.debug('onIdle', { documentManagerIdle: this.documentManager.isIdle, fileChangesQueueIdle: this.fileChangesQueue.isIdle });
9✔
285

286
            await Promise.allSettled([
9✔
287
                //make sure all pending file changes have been flushed
288
                this.documentManager.onIdle(),
289
                //wait for the file changes queue to be idle
290
                this.fileChangesQueue.onIdle()
291
            ]);
292
        }
293

294
        this.logger.info('onIdle debug', { documentManagerIdle: this.documentManager.isIdle, fileChangesQueueIdle: this.fileChangesQueue.isIdle });
33✔
295
    }
296

297
    /**
298
     * Given a list of all desired projects, create any missing projects and destroy and projects that are no longer available
299
     * Treat workspaces that don't have a bsconfig.json as a project.
300
     * Handle situations where bsconfig.json files were added or removed (to elevate/lower workspaceFolder projects accordingly)
301
     * Leave existing projects alone if they are not affected by these changes
302
     * @param workspaceConfigs an array of workspaces
303
     */
304
    @TrackBusyStatus
305
    public async syncProjects(workspaceConfigs: WorkspaceConfig[], forceReload = false) {
1✔
306
        //if we're force reloading, destroy all projects and start fresh
307
        if (forceReload) {
91✔
308
            this.logger.log('syncProjects: forceReload is true so removing all existing projects');
3✔
309
            for (const project of this.projects) {
3✔
310
                this.removeProject(project);
4✔
311
            }
312
        }
313
        this.logger.log('syncProjects', workspaceConfigs.map(x => x.workspaceFolder));
91✔
314

315
        const generation = ++this.syncGeneration;
91✔
316

317
        this.syncPromise = (async () => {
91✔
318
            //build a list of unique projects across all workspace folders
319
            let projectConfigs = (await Promise.all(
91✔
320
                workspaceConfigs.map(async workspaceConfig => {
321
                    const discoveredProjects = await this.discoverProjectsForWorkspace(workspaceConfig);
90✔
322
                    return discoveredProjects.map<ProjectConfig>(discoveredProject => ({
143✔
323
                        name: discoveredProject?.name,
429!
324
                        projectKey: s`${discoveredProject.bsconfigPath ?? discoveredProject.dir}`,
429✔
325
                        projectDir: s`${discoveredProject.dir}`,
326
                        bsconfigPath: discoveredProject?.bsconfigPath,
429!
327
                        workspaceFolder: s`${workspaceConfig.workspaceFolder}`,
328
                        excludePatterns: workspaceConfig.excludePatterns,
329
                        enableThreading: workspaceConfig.languageServer.enableThreading
330
                    }));
331
                })
332
            )).flat(1);
333

334
            //TODO handle when a project came from the workspace config .projects array (it should probably never be filtered out)
335
            //filter the project paths to only include those that are allowed by the path filterer
336
            projectConfigs = this.pathFilterer.filter(projectConfigs, x => x.projectKey);
91✔
337

338
            //delete projects not represented in the list
339
            for (const project of this.projects) {
91✔
340
                //we can't find this existing project in our new list, so scrap it
341
                if (!projectConfigs.find(x => x.projectKey === project.projectKey)) {
14✔
342
                    this.removeProject(project);
6✔
343
                }
344
            }
345

346
            // skip projects we already have (they're already loaded...no need to reload them)
347
            projectConfigs = projectConfigs.filter(x => {
91✔
348
                return !this.hasProject(x);
143✔
349
            });
350

351
            //dedupe by projectKey
352
            projectConfigs = [
91✔
353
                ...projectConfigs.reduce(
354
                    (acc, x) => acc.set(x.projectKey, x),
138✔
355
                    new Map<string, typeof projectConfigs[0]>()
356
                ).values()
357
            ];
358

359
            // Phase 1: activate projects with concurrency limit (awaited — gates LSP readiness)
360
            const activatedProjects: LspProject[] = [];
91✔
361
            await this.runWithConcurrencyLimit(projectConfigs, this.projectActivationConcurrencyLimit, async (config) => {
91✔
362
                if (this.syncGeneration !== generation) {
137✔
363
                    return;
5✔
364
                }
365
                const project = await this.createAndActivateProject(config);
132✔
366
                activatedProjects.push(project);
132✔
367
            });
368

369
            //mark that we've completed our first sync
370
            this.firstSync.tryResolve();
91✔
371

372
            // Phase 2: validate activated projects with concurrency limit (NOT awaited — doesn't block LSP requests)
373
            if (this.syncGeneration === generation) {
91✔
374
                void this.runWithConcurrencyLimit(activatedProjects, this.projectActivationConcurrencyLimit, async (project) => {
89✔
375
                    if (this.syncGeneration !== generation) {
123!
NEW
376
                        return;
×
377
                    }
378
                    await project.validate();
123✔
379
                }).catch(e => this.logger.error('Validation phase error', e));
3✔
380
            }
381
        })();
382

383
        //return the sync promise
384
        return this.syncPromise;
91✔
385
    }
386

387
    /**
388
     * Run async actions over a list of items with a concurrency limit.
389
     * Uses a worker-pool pattern: `concurrencyLimit` workers pull items from a shared queue.
390
     */
391
    private async runWithConcurrencyLimit<T>(items: T[], concurrencyLimit: number, action: (item: T) => Promise<void>): Promise<void> {
392
        const queue = [...items];
180✔
393
        if (queue.length === 0) {
180✔
394
            return;
12✔
395
        }
396
        const workerCount = Math.max(1, Math.min(concurrencyLimit, queue.length));
168✔
397
        const workers = Array.from({ length: workerCount }, async () => {
168✔
398
            while (queue.length > 0) {
210✔
399
                const item = queue.shift();
260✔
400
                if (item) {
260!
401
                    await action(item);
260✔
402
                }
403
            }
404
        });
405
        await Promise.all(workers);
168✔
406
    }
407

408
    private fileChangesQueue = new ActionQueue({
141✔
409
        maxActionDuration: 45_000
410
    });
411

412
    public handleFileChanges(changes: FileChange[]): Promise<void> {
413
        this.logger.debug('handleFileChanges', changes.map(x => `${FileChangeTypeLookup[x.type]}: ${x.srcPath}`));
78✔
414

415
        //this function should NOT be marked as async, because typescript wraps the body in an async call sometimes. These need to be registered synchronously
416
        return this.fileChangesQueue.run(async (changes) => {
69✔
417
            //wait for any pending syncs to finish
418
            await this.onInitialized();
69✔
419

420
            return this._handleFileChanges(changes);
69✔
421
        }, changes);
422
    }
423

424
    /**
425
     * Handle when files or directories are added, changed, or deleted in the workspace.
426
     * This is safe to call any time. Changes will be queued and flushed at the correct times
427
     */
428
    private async _handleFileChanges(changes: FileChange[]) {
429
        //normalize srcPath for all changes
430
        for (const change of changes) {
69✔
431
            change.srcPath = util.standardizePath(change.srcPath);
78✔
432
        }
433

434
        //filter any changes that are not allowed by the path filterer
435
        changes = this.pathFilterer.filter(changes, x => x.srcPath);
69✔
436

437
        this.logger.debug('handleFileChanges -> filtered', changes.map(x => `${FileChangeTypeLookup[x.type]}: ${x.srcPath}`));
77✔
438

439
        //process all file changes in parallel
440
        await Promise.all(changes.map(async (change) => {
69✔
441
            await this.handleFileChange(change);
77✔
442
        }));
443
    }
444

445
    /**
446
     * Handle a single file change. If the file is a directory, this will recursively read all files in the directory and call `handleFileChanges` again
447
     */
448
    private async handleFileChange(change: FileChange) {
449
        if (change.type === FileChangeType.Deleted) {
81✔
450
            //mark this document or directory as deleted
451
            this.documentManager.delete(change.srcPath);
1✔
452

453
            //file added or changed
454
        } else {
455
            //if this is a new directory, read all files recursively and register those as file changes too
456
            if (util.isDirectorySync(change.srcPath)) {
80✔
457
                const files = await fastGlob('**/*', {
2✔
458
                    cwd: change.srcPath,
459
                    onlyFiles: true,
460
                    absolute: true
461
                });
462
                //pipe all files found recursively in the new directory through this same function so they can be processed correctly
463
                await Promise.all(files.map((srcPath) => {
2✔
464
                    return this.handleFileChange({
6✔
465
                        srcPath: util.standardizePath(srcPath),
466
                        type: FileChangeType.Changed,
467
                        allowStandaloneProject: change.allowStandaloneProject
468
                    });
469
                }));
470

471
                //this is a new file. set the file contents
472
            } else {
473
                this.documentManager.set({
78✔
474
                    srcPath: change.srcPath,
475
                    fileContents: change.fileContents,
476
                    allowStandaloneProject: change.allowStandaloneProject
477
                });
478
            }
479
        }
480

481
        //reload any projects whose bsconfig.json was changed
482
        const projectsToReload = this.projects.filter(project => {
81✔
483
            //this is a path to a bsconfig.json file
484
            if (project.bsconfigPath?.toLowerCase() === change.srcPath.toLowerCase()) {
95✔
485
                //fetch file contents if we don't already have them
486
                if (!change.fileContents) {
4!
487
                    try {
4✔
488
                        change.fileContents = fsExtra.readFileSync(project.bsconfigPath).toString();
4✔
489
                    } finally { }
490
                }
491
                ///the bsconfig contents have changed since we last saw it, so reload this project
492
                if (project.bsconfigFileContents !== change.fileContents) {
4✔
493
                    return true;
3✔
494
                }
495
            }
496
            return false;
92✔
497
        });
498

499
        if (projectsToReload.length > 0) {
81✔
500
            await Promise.all(
3✔
501
                projectsToReload.map(x => this.reloadProject(x))
3✔
502
            );
503
        }
504
    }
505

506
    /**
507
     * Handle when a file is closed in the editor (this mostly just handles removing standalone projects)
508
     */
509
    public async handleFileClose(event: { srcPath: string }) {
510
        this.logger.debug(`File was closed. ${event.srcPath}`);
1✔
511
        this.removeStandaloneProject(event.srcPath);
1✔
512
        //most other methods on this class are async, might as well make this one async too for consistency and future expansion
513
        await Promise.resolve();
1✔
514
    }
515

516
    /**
517
     * Given a project, forcibly reload it by removing it and re-adding it
518
     */
519
    private async reloadProject(project: LspProject) {
520
        this.logger.log('Reloading project', { projectPath: project.projectKey });
3✔
521

522
        this.removeProject(project);
3✔
523
        project = await this.createAndActivateProject(project.activateOptions);
3✔
524
        void project.validate();
3✔
525
    }
526

527
    /**
528
     * Get all the semantic tokens for the given file
529
     * @returns an array of semantic tokens
530
     */
531
    @TrackBusyStatus
532
    public async getSemanticTokens(options: { srcPath: string }) {
1✔
533
        //wait for all pending syncs to finish
534
        await this.onIdle();
1✔
535

536
        let result = await util.promiseRaceMatch(
1✔
537
            this.projects.map(x => x.getSemanticTokens(options)),
1✔
538
            //keep the first non-falsey result
539
            (result) => result?.length > 0
1!
540
        );
541
        return result;
1✔
542
    }
543

544
    /**
545
     * Get a string containing the transpiled contents of the file at the given path
546
     * @returns the transpiled contents of the file as a string
547
     */
548
    @TrackBusyStatus
549
    public async transpileFile(options: { srcPath: string }) {
1✔
550
        //wait for all pending syncs to finish
551
        await this.onIdle();
2✔
552

553
        let result = await util.promiseRaceMatch(
2✔
554
            this.projects.map(x => x.transpileFile(options)),
2✔
555
            //keep the first non-falsey result
556
            (result) => !!result
2✔
557
        );
558
        return result;
2✔
559
    }
560

561
    /**
562
     *  Get the completions for the given position in the file
563
     */
564
    @TrackBusyStatus
565
    public async getCompletions(options: { srcPath: string; position: Position; cancellationToken?: CancellationToken }): Promise<CompletionList> {
1✔
566
        await this.onIdle();
1✔
567

568
        //if the request has been cancelled since originally requested due to idle time being slow, skip the rest of the wor
569
        if (options?.cancellationToken?.isCancellationRequested) {
1!
570
            this.logger.debug('ProjectManager getCompletions cancelled', options);
×
571
            return;
×
572
        }
573

574
        this.logger.debug('ProjectManager getCompletions', options);
1✔
575
        //Ask every project for results, keep whichever one responds first that has a valid response
576
        let result = await util.promiseRaceMatch(
1✔
577
            this.projects.map(x => x.getCompletions(options)),
1✔
578
            //keep the first non-falsey result
579
            (result) => result?.items?.length > 0
1!
580
        );
581
        return result;
1✔
582
    }
583

584
    /**
585
     * Get the hover information for the given position in the file. If multiple projects have hover information, the projects will be raced and
586
     * the fastest result will be returned
587
     * @returns the hover information or undefined if no hover information was found
588
     */
589
    @TrackBusyStatus
590
    public async getHover(options: { srcPath: string; position: Position }): Promise<Hover> {
1✔
591
        //wait for all pending syncs to finish
592
        await this.onIdle();
×
593

594
        //Ask every project for hover info, keep whichever one responds first that has a valid response
595
        let hover = await util.promiseRaceMatch(
×
596
            this.projects.map(x => x.getHover(options)),
×
597
            //keep the first set of non-empty results
598
            (result) => result?.length > 0
×
599
        );
600
        return hover?.[0];
×
601
    }
602

603
    /**
604
     * Get the definition for the symbol at the given position in the file
605
     * @returns a list of locations where the symbol under the position is defined in the project
606
     */
607
    @TrackBusyStatus
608
    public async getDefinition(options: { srcPath: string; position: Position }): Promise<Location[]> {
1✔
609
        //wait for all pending syncs to finish
610
        await this.onIdle();
5✔
611

612
        //TODO should we merge definitions across ALL projects? or just return definitions from the first project we found
613

614
        //Ask every project for definition info, keep whichever one responds first that has a valid response
615
        let result = await util.promiseRaceMatch(
5✔
616
            this.projects.map(x => x.getDefinition(options)),
5✔
617
            //keep the first non-falsey result
618
            (result) => !!result
5✔
619
        );
620
        return result;
5✔
621
    }
622

623
    @TrackBusyStatus
624
    public async getSignatureHelp(options: { srcPath: string; position: Position }): Promise<SignatureHelp> {
1✔
625
        //wait for all pending syncs to finish
626
        await this.onIdle();
4✔
627

628
        //Ask every project for definition info, keep whichever one responds first that has a valid response
629
        let signatures = await util.promiseRaceMatch(
4✔
630
            this.projects.map(x => x.getSignatureHelp(options)),
4✔
631
            //keep the first non-falsey result
632
            (result) => !!result
4✔
633
        );
634

635
        if (signatures?.length > 0) {
4!
636
            const activeSignature = signatures.length > 0 ? 0 : undefined;
3!
637

638
            const activeParameter = activeSignature >= 0 ? signatures[activeSignature]?.index : undefined;
3!
639

640
            let result: SignatureHelp = {
3✔
641
                signatures: signatures.map((s) => s.signature),
3✔
642
                activeSignature: activeSignature,
643
                activeParameter: activeParameter
644
            };
645
            return result;
3✔
646
        }
647
    }
648

649
    @TrackBusyStatus
650
    public async getDocumentSymbol(options: { srcPath: string }): Promise<DocumentSymbol[]> {
1✔
651
        //wait for all pending syncs to finish
652
        await this.onIdle();
6✔
653

654
        //Ask every project for definition info, keep whichever one responds first that has a valid response
655
        let result = await util.promiseRaceMatch(
6✔
656
            this.projects.map(x => x.getDocumentSymbol(options)),
6✔
657
            //keep the first non-falsey result
658
            (result) => !!result
6✔
659
        );
660
        return result;
6✔
661
    }
662

663
    @TrackBusyStatus
664
    public async getWorkspaceSymbol(): Promise<WorkspaceSymbol[]> {
1✔
665
        //wait for all pending syncs to finish
666
        await this.onIdle();
5✔
667

668
        //Ask every project for definition info, keep whichever one responds first that has a valid response
669
        let responses = await Promise.allSettled(
5✔
670
            this.projects.map(x => x.getWorkspaceSymbol())
5✔
671
        );
672
        let results = responses
5✔
673
            //keep all symbol results
674
            .map((x) => {
675
                return x.status === 'fulfilled' ? x.value : [];
5✔
676
            })
677
            //flatten the array
678
            .flat()
679
            //throw out nulls
680
            .filter(x => !!x);
24✔
681

682
        // Remove duplicates
683
        const allSymbols = Object.values(
5✔
684
            results.reduce((map, symbol) => {
685
                const key = symbol.location.uri + symbol.name;
24✔
686
                map[key] = symbol;
24✔
687
                return map;
24✔
688
            }, {})
689
        );
690

691
        return allSymbols as SymbolInformation[];
5✔
692
    }
693

694
    @TrackBusyStatus
695
    public async getReferences(options: { srcPath: string; position: Position }): Promise<Location[]> {
1✔
696
        //wait for all pending syncs to finish
697
        await this.onIdle();
3✔
698

699
        //Ask every project for definition info, keep whichever one responds first that has a valid response
700
        let result = await util.promiseRaceMatch(
3✔
701
            this.projects.map(x => x.getReferences(options)),
3✔
702
            //keep the first non-falsey result
703
            (result) => !!result
3✔
704
        );
705
        return result ?? [];
3!
706
    }
707

708
    @TrackBusyStatus
709
    public async getCodeActions(options: { srcPath: string; range: Range }) {
1✔
710
        //wait for all pending syncs to finish
711
        await this.onIdle();
×
712

713
        //Ask every project for definition info, keep whichever one responds first that has a valid response
714
        let result = await util.promiseRaceMatch(
×
715
            this.projects.map(x => x.getCodeActions(options)),
×
716
            //keep the first non-falsey result
717
            (result) => !!result
×
718
        );
719
        return result;
×
720
    }
721

722
    @TrackBusyStatus
723
    public async getSelectionRanges(options: { srcPath: string; positions: Position[] }): Promise<SelectionRange[]> {
1✔
724
        //wait for all pending syncs to finish
725
        await this.onIdle();
×
726

727
        //Ask every project for selection ranges, keep whichever one responds first with a non-empty result
728
        let result = await util.promiseRaceMatch(
×
729
            this.projects.map(x => x.getSelectionRanges(options)),
×
730
            //keep the first non-empty result
731
            (result) => result?.length > 0
×
732
        );
733
        return result ?? [];
×
734
    }
735

736
    /**
737
     * Scan a given workspace for all `bsconfig.json` files. If at least one is found, then only folders who have bsconfig.json are returned.
738
     * If none are found, then the workspaceFolder itself is treated as a project
739
     */
740
    private async discoverProjectsForWorkspace(workspaceConfig: WorkspaceConfig): Promise<DiscoveredProject[]> {
741
        //config may provide a list of project paths. If we have these, no other discovery is permitted
742
        if (Array.isArray(workspaceConfig.projects) && workspaceConfig.projects.length > 0) {
90✔
743
            this.logger.debug(`Using project paths from workspace config`, workspaceConfig.projects);
3✔
744
            const projectConfigs = workspaceConfig.projects.reduce<DiscoveredProject[]>((acc, project) => {
3✔
745
                //skip this project if it's disabled or we don't have a path
746
                if (project.disabled || !project.path) {
5!
747
                    return acc;
×
748
                }
749
                //ensure the project path is absolute
750
                if (!path.isAbsolute(project.path)) {
5!
751
                    project.path = path.resolve(workspaceConfig.workspaceFolder, project.path);
×
752
                }
753

754
                //skip this project if the path does't exist
755
                if (!fsExtra.existsSync(project.path)) {
5!
756
                    return acc;
×
757
                }
758

759
                //if the project is a directory
760
                if (fsExtra.statSync(project.path).isDirectory()) {
5✔
761
                    acc.push({
2✔
762
                        name: project.name,
763
                        bsconfigPath: undefined,
764
                        dir: project.path
765
                    });
766
                    //it's a path to a file (hopefully bsconfig.json)
767
                } else {
768
                    acc.push({
3✔
769
                        name: project.name,
770
                        dir: path.dirname(project.path),
771
                        bsconfigPath: project.path
772
                    });
773
                }
774
                return acc;
5✔
775
            }, []);
776

777
            //if we didn't find any valid project paths, log a warning. having zero projects is acceptable, it typically means the user wanted to disable discovery or
778
            //disabled all their projects on purpose
779
            if (projectConfigs.length === 0) {
3!
780
                this.logger.warn(`No valid project paths found in workspace config`, JSON.stringify(workspaceConfig.projects, null, 4));
×
781
            }
782
            return projectConfigs;
3✔
783
        }
784

785
        //automatic discovery disabled?
786
        if (!workspaceConfig.languageServer.enableProjectDiscovery) {
87✔
787
            return [{
1✔
788
                dir: workspaceConfig.workspaceFolder
789
            }];
790
        }
791

792
        //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
793
        const excludePatterns = (workspaceConfig.excludePatterns ?? []).map(x => s`!${x}`.replace(/[\\/]+/g, '/'));
86✔
794

795
        let files = await fastGlob(['**/bsconfig.json', ...excludePatterns], {
86✔
796
            cwd: workspaceConfig.workspaceFolder,
797
            followSymbolicLinks: false,
798
            absolute: true,
799
            onlyFiles: true,
800
            deep: workspaceConfig.languageServer.projectDiscoveryMaxDepth ?? 15
258✔
801
        });
802

803
        //filter the files to only include those that are allowed by the path filterer
804
        files = this.pathFilterer.filter(files);
86✔
805

806
        //if we found at least one bsconfig.json, then ALL projects must have a bsconfig.json.
807
        if (files.length > 0) {
86✔
808
            return files.map(file => ({
85✔
809
                dir: s`${path.dirname(file)}`,
810
                bsconfigPath: s`${file}`
811
            }));
812
        }
813

814
        //look for roku project folders
815
        let rokuLikeDirs = (await Promise.all(
47✔
816
            //find all folders containing a `manifest` file
817
            (await fastGlob(['**/manifest', ...excludePatterns], {
818
                cwd: workspaceConfig.workspaceFolder,
819
                followSymbolicLinks: false,
820
                absolute: true,
821
                onlyFiles: true,
822
                deep: workspaceConfig.languageServer.projectDiscoveryMaxDepth ?? 15
141✔
823
            })).map(async manifestEntry => {
824
                const manifestDir = path.dirname(manifestEntry);
11✔
825
                //TODO validate that manifest is a Roku manifest
826
                const files = await rokuDeploy.getFilePaths([
11✔
827
                    'source/**/*.{brs,bs}',
828
                    ...excludePatterns
829
                ], manifestDir);
830
                if (files.length > 0) {
11✔
831
                    return s`${manifestDir}`;
9✔
832
                }
833
            })
834
            //throw out nulls
835
        )).filter(x => !!x);
11✔
836

837
        //throw out any directories that are not allowed by the path filterer
838
        rokuLikeDirs = this.pathFilterer.filter(rokuLikeDirs, srcPath => srcPath);
47✔
839

840
        if (rokuLikeDirs.length > 0) {
47✔
841
            return rokuLikeDirs.map(file => ({
9✔
842
                dir: file
843
            }));
844
        }
845

846
        //treat the workspace folder as a brightscript project itself
847
        return [{
43✔
848
            dir: workspaceConfig.workspaceFolder
849
        }];
850
    }
851

852
    /**
853
     * Returns true if we have this project, or false if we don't
854
     * @returns true if the project exists, or false if it doesn't
855
     */
856
    private hasProject(config: Partial<ProjectConfig>) {
857
        return !!this.getProject(config);
423✔
858
    }
859

860
    /**
861
     * Get a project with the specified path
862
     * @param param path to the project or an obj that has `projectPath` prop
863
     * @returns a project, or undefined if no project was found
864
     */
865
    private getProject(param: string | Partial<ProjectConfig>) {
866
        const projectKey = util.standardizePath(
425✔
867
            (typeof param === 'string') ? param : (param?.projectKey ?? param?.bsconfigPath ?? param?.projectDir)
4,241!
868
        );
869
        if (!projectKey) {
425!
870
            return;
×
871
        }
872
        return this.projects.find(x => x.projectKey === projectKey);
425✔
873
    }
874

875
    /**
876
     * Remove a project from the language server
877
     */
878
    private removeProject(project: LspProject) {
879
        const idx = this.projects.findIndex(x => x.projectKey === project?.projectKey);
19✔
880
        if (idx > -1) {
19✔
881
            this.logger.log('Removing project', { projectKey: project.projectKey, projectNumber: project.projectNumber });
16✔
882
            this.projects.splice(idx, 1);
16✔
883
        }
884
        //anytime we remove a project, we should emit an event that clears all of its diagnostics
885
        this.emit('diagnostics', { project: project, diagnostics: [] });
19✔
886
        project?.dispose();
19✔
887
        this.busyStatusTracker.endAllRunsForScope(project);
19✔
888
    }
889

890
    /**
891
     * A unique project counter to help distinguish log entries in lsp mode
892
     */
893
    private static projectNumberSequence = 0;
1✔
894

895
    private static projectNumberCache = new Cache<string, number>();
1✔
896

897
    /**
898
     * Get a projectNumber for a given config. Try to reuse project numbers when we've seen this project before
899
     *  - If the config already has one, use that.
900
     *  - If we've already seen this config before, use the same project number as before
901
     */
902
    private getProjectNumber(config: ProjectConfig) {
903
        if (config.projectNumber !== undefined) {
142✔
904
            return config.projectNumber;
9✔
905
        }
906
        const key = s`${config.projectKey}` + '-' + s`${config.workspaceFolder}` + '-' + s`${config.bsconfigPath}`;
133✔
907
        return ProjectManager.projectNumberCache.getOrAdd(key, () => {
133✔
908
            return ProjectManager.projectNumberSequence++;
30✔
909
        });
910
    }
911

912
    /**
913
     * Constructs a project for the given config. Just makes the project, doesn't activate it
914
     * @returns a new project, or the existing project if one already exists with this config info
915
     */
916
    private constructProject(config: ProjectConfig): LspProject {
917
        //skip this project if we already have it
918
        if (this.hasProject(config)) {
142!
919
            return this.getProject(config);
×
920
        }
921

922
        config.projectNumber = this.getProjectNumber(config);
142✔
923

924
        let project: LspProject = config.enableThreading
142✔
925
            ? new WorkerThreadProject({
142✔
926
                logger: this.logger.createLogger()
927
            })
928
            : new Project({
929
                logger: this.logger.createLogger()
930
            });
931

932
        this.logger.log(`Created project #${config.projectNumber} for: "${config.projectKey}" (${config.enableThreading ? 'worker thread' : 'main thread'})`);
142✔
933

934
        this.projects.push(project);
142✔
935

936
        //pipe all project-specific events through our emitter, and include the project reference
937
        project.on('all', (eventName, data) => {
142✔
938
            this.emit(eventName as any, {
389✔
939
                ...data,
940
                project: project
941
            } as any);
942
        });
943
        return project;
142✔
944
    }
945

946
    /**
947
     * Constructs a project for the given config
948
     * @returns a new project, or the existing project if one already exists with this config info
949
     */
950
    @TrackBusyStatus
951
    private async createAndActivateProject(config: ProjectConfig): Promise<LspProject> {
1✔
952
        //skip this project if we already have it
953
        if (this.hasProject(config)) {
138✔
954
            return this.getProject(config.projectKey);
1✔
955
        }
956
        const project = this.constructProject(config);
137✔
957
        await this.activateProject(project, config);
137✔
958
        return project;
136✔
959
    }
960

961
    @TrackBusyStatus
962
    private async activateProject(project: LspProject, config: ProjectConfig) {
1✔
963
        this.logger.debug('Activating project', util.getProjectLogName(project), {
115✔
964
            projectPath: config?.projectKey,
345!
965
            bsconfigPath: config.bsconfigPath
966
        });
967
        await project.activate(config);
115✔
968

969
        //send an event to indicate that this project has been activated
970
        this.emit('project-activate', { project: project });
114✔
971

972
        //register this project's list of files with the path filterer
973
        const unregister = this.pathFilterer.registerIncludeList(project.rootDir, project.filePatterns);
114✔
974
        project.disposables.push({ dispose: unregister });
114✔
975
    }
976

977
    public on(eventName: 'validate-begin', handler: (data: { project: LspProject }) => MaybePromise<void>);
978
    public on(eventName: 'validate-end', handler: (data: { project: LspProject }) => MaybePromise<void>);
979
    public on(eventName: 'critical-failure', handler: (data: { project: LspProject; message: string }) => MaybePromise<void>);
980
    public on(eventName: 'project-activate', handler: (data: { project: LspProject }) => MaybePromise<void>);
981
    public on(eventName: 'diagnostics', handler: (data: { project: LspProject; diagnostics: LspDiagnostic[] }) => MaybePromise<void>);
982
    public on(eventName: string, handler: (payload: any) => MaybePromise<void>) {
983
        this.emitter.on(eventName, handler as any);
515✔
984
        return () => {
515✔
985
            this.emitter.removeListener(eventName, handler as any);
1✔
986
        };
987
    }
988

989
    private emit(eventName: 'validate-begin', data: { project: LspProject });
990
    private emit(eventName: 'validate-end', data: { project: LspProject });
991
    private emit(eventName: 'critical-failure', data: { project: LspProject; message: string });
992
    private emit(eventName: 'project-activate', data: { project: LspProject });
993
    private emit(eventName: 'diagnostics', data: { project: LspProject; diagnostics: LspDiagnostic[] });
994
    private async emit(eventName: string, data?) {
995
        //emit these events on next tick, otherwise they will be processed immediately which could cause issues
996
        await util.sleep(0);
527✔
997
        this.emitter.emit(eventName, data);
527✔
998
    }
999
    private emitter = new EventEmitter();
141✔
1000

1001
    public dispose() {
1002
        this.emitter.removeAllListeners();
141✔
1003
        for (const project of this.projects) {
141✔
1004
            project?.dispose?.();
127!
1005
        }
1006
    }
1007
}
1008

1009
export interface WorkspaceConfig {
1010
    /**
1011
     * Absolute path to the folder where the workspace resides
1012
     */
1013
    workspaceFolder: string;
1014
    /**
1015
     * A list of glob patterns used to _exclude_ files from various bsconfig searches
1016
     */
1017
    excludePatterns?: string[];
1018
    /**
1019
     * A list of project paths that should be used to create projects in place of discovery.
1020
     */
1021
    projects?: BrightScriptProjectConfiguration[];
1022
    /**
1023
     * Language server configuration options
1024
     */
1025
    languageServer: {
1026
        /**
1027
         * Should the projects in this workspace be run in their own dedicated worker threads, or all run on the main thread
1028
         */
1029
        enableThreading: boolean;
1030
        /**
1031
         * Should the language server automatically discover projects in this workspace?
1032
         */
1033
        enableProjectDiscovery: boolean;
1034
        /**
1035
         * A list of glob patterns used to _exclude_ files from project discovery
1036
         */
1037
        projectDiscoveryExclude?: Record<string, boolean>;
1038
        /**
1039
         * The log level to use for this workspace
1040
         */
1041
        logLevel?: LogLevel | string;
1042
        /**
1043
         * Maximum depth to search for Roku projects
1044
         */
1045
        projectDiscoveryMaxDepth?: number;
1046
        /**
1047
         * The maximum number of projects that can be activated concurrently
1048
         */
1049
        projectActivationConcurrencyLimit?: number;
1050
    };
1051
}
1052

1053
interface StandaloneProject extends LspProject {
1054
    /**
1055
     * The path to the file that this project represents
1056
     */
1057
    srcPath: string;
1058
}
1059

1060
/**
1061
 * An annotation used to wrap the method in a busyStatus tracking call
1062
 */
1063
function TrackBusyStatus(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
1064
    let originalMethod = descriptor.value;
15✔
1065

1066
    //wrapping the original method
1067
    descriptor.value = function value(this: ProjectManager, ...args: any[]) {
15✔
1068
        return this.busyStatusTracker.run(() => {
414✔
1069
            return originalMethod.apply(this, args);
414✔
1070
        }, originalMethod.name);
1071
    };
1072
}
1073

1074
interface DiscoveredProject {
1075
    name?: string;
1076
    bsconfigPath?: string;
1077
    dir: string;
1078
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc