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

rokucommunity / brighterscript / #14375

08 May 2025 08:02PM UTC coverage: 87.105% (-1.9%) from 89.017%
#14375

push

web-flow
Merge bb74432dc into 489231ac7

13490 of 16372 branches covered (82.4%)

Branch coverage included in aggregate %.

8031 of 8709 new or added lines in 103 files covered. (92.21%)

84 existing lines in 22 files now uncovered.

14463 of 15719 relevant lines covered (92.01%)

19993.16 hits per line

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

89.96
/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 } 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 } 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

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

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

46
        this.on('validate-begin', (event) => {
89✔
47
            this.busyStatusTracker.beginScopedRun(event.project, `validate-project`);
94✔
48
        });
49
        this.on('validate-end', (event) => {
89✔
50
            void this.busyStatusTracker.endScopedRun(event.project, `validate-project`);
91✔
51
        });
52
    }
53

54
    private pathFilterer: PathFilterer;
55

56
    private logger: Logger;
57

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

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

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

72
    public busyStatusTracker = new BusyStatusTracker<LspProject>();
89✔
73

74
    /**
75
     * Apply all of the queued document changes. This should only be called as a result of the documentManager flushing changes, and never called manually
76
     * @param event the document changes that have occurred since the last time we applied
77
     */
78
    @TrackBusyStatus
79
    private async flushDocumentChanges(event: FlushEvent) {
1✔
80

81
        this.logger.info(`flushDocumentChanges`, event?.actions?.map(x => ({
76!
82
            type: x.type,
83
            srcPath: x.srcPath,
84
            allowStandaloneProject: x.allowStandaloneProject
85
        })));
86

87
        //ensure that we're fully initialized before proceeding
88
        await this.onInitialized();
41✔
89

90
        const actions = [...event.actions] as DocumentActionWithStatus[];
41✔
91

92
        let idSequence = 0;
41✔
93
        //add an ID to every action (so we can track which actions were handled by which projects)
94
        for (const action of actions) {
41✔
95
            action.id = idSequence++;
76✔
96
        }
97

98
        //apply all of the document actions to each project in parallel
99
        const responses = await Promise.all(this.projects.map(async (project) => {
41✔
100
            //wait for this project to finish activating
101
            await project.whenActivated();
48✔
102

103
            const filterer = new PathCollection({
48✔
104
                rootDir: project.rootDir,
105
                globs: project.filePatterns
106
            });
107
            // only include files that are applicable to this specific project (still allow deletes to flow through since they're cheap)
108
            const projectActions = actions.filter(action => {
48✔
109
                return (
88✔
110
                    //if this is a delete, just pass it through because they're cheap to apply
111
                    action.type === 'delete' ||
175✔
112
                    //if this is a set, only pass it through if it's a file that this project cares about
113
                    filterer.isMatch(action.srcPath)
114
                );
115
            });
116
            if (projectActions.length > 0) {
48✔
117
                const responseActions = await project.applyFileChanges(projectActions);
37✔
118
                return responseActions.map(x => ({
69✔
119
                    project: project,
120
                    action: x
121
                }));
122
            }
123
        }));
124

125
        //create standalone projects for any files not handled by any project
126
        const flatResponses = responses.flat();
41✔
127
        for (const action of actions) {
41✔
128
            //skip this action if it doesn't support standalone projects
129
            if (!action.allowStandaloneProject || action.type !== 'set') {
76✔
130
                continue;
27✔
131
            }
132

133
            //a list of responses that handled this action
134
            const handledResponses = flatResponses.filter(x => x?.action?.id === action.id && x?.action?.status === 'accepted');
142!
135

136
            //remove any standalone project created for this file since it was handled by a normal project
137
            const normalProjectsThatHandledThisFile = handledResponses.filter(x => !x.project.isStandaloneProject);
49✔
138
            if (normalProjectsThatHandledThisFile.length > 0) {
49✔
139
                //if there's a standalone project for this file, delete it
140
                if (this.getStandaloneProject(action.srcPath, false)) {
40✔
141
                    this.logger.debug(
1✔
142
                        `flushDocumentChanges: removing standalone project because the following normal projects handled the file: '${action.srcPath}', projects:`,
143
                        normalProjectsThatHandledThisFile.map(x => x.project.projectIdentifier)
1✔
144
                    );
145
                    this.removeStandaloneProject(action.srcPath);
1✔
146
                }
147

148
                // create a standalone project if this action was handled by zero normal projects.
149
                //(safe to call even if there's already a standalone project, won't create dupes)
150
            } else {
151
                //TODO only create standalone projects for files we understand (brightscript, brighterscript, scenegraph xml, etc)
152
                await this.createStandaloneProject(action.srcPath);
9✔
153
            }
154
        }
155
        this.logger.info('flushDocumentChanges complete', actions.map(x => ({
76✔
156
            type: x.type,
157
            srcPath: x.srcPath,
158
            allowStandaloneProject: x.allowStandaloneProject
159
        })));
160
    }
161

162
    /**
163
     * Get a standalone project for a given file path
164
     */
165
    private getStandaloneProject(srcPath: string, standardizePath = true) {
×
166
        return this.standaloneProjects.get(
51✔
167
            standardizePath ? util.standardizePath(srcPath) : srcPath
51!
168
        );
169
    }
170

171
    /**
172
     * Create a project that validates a single file. This is useful for getting language support for files that don't belong to a project
173
     */
174
    private async createStandaloneProject(srcPath: string) {
175
        srcPath = util.standardizePath(srcPath);
9✔
176

177
        //if we already have a standalone project with this path, do nothing because it already exists
178
        if (this.getStandaloneProject(srcPath, false)) {
9✔
179
            this.logger.log('createStandaloneProject skipping because we already have one for this path');
4✔
180
            return;
4✔
181
        }
182

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

185
        const projectNumber = ProjectManager.projectNumberSequence++;
5✔
186
        const rootDir = path.join(__dirname, `standalone-project-${projectNumber}`);
5✔
187
        const projectOptions = {
5✔
188
            //these folders don't matter for standalone projects
189
            workspaceFolder: rootDir,
190
            projectPath: rootDir,
191
            enableThreading: false,
192
            projectNumber: projectNumber,
193
            files: [{
194
                src: srcPath,
195
                dest: 'source/standalone.brs'
196
            }]
197
        };
198

199
        const project = this.constructProject(projectOptions) as StandaloneProject;
5✔
200
        project.srcPath = srcPath;
5✔
201
        project.isStandaloneProject = true;
5✔
202

203
        this.standaloneProjects.set(srcPath, project);
5✔
204
        await this.activateProject(project, projectOptions);
5✔
205
    }
206

207
    private removeStandaloneProject(srcPath: string) {
208
        srcPath = util.standardizePath(srcPath);
2✔
209
        const project = this.getStandaloneProject(srcPath, false);
2✔
210
        if (project) {
2!
211
            if (project.srcPath === srcPath) {
2!
212
                this.logger.debug(`Removing standalone project for file '${srcPath}'`);
2✔
213
                this.removeProject(project);
2✔
214
                this.standaloneProjects.delete(srcPath);
2✔
215
            }
216
        }
217
    }
218

219
    /**
220
     * A promise that's set when a sync starts, and resolved when the sync is complete
221
     */
222
    private syncPromise: Promise<void> | undefined;
223
    private firstSync = new Deferred();
89✔
224

225
    /**
226
     * Get a promise that resolves when this manager is finished initializing
227
     */
228
    public onInitialized() {
229
        return Promise.allSettled([
145✔
230
            //wait for the first sync to finish
231
            this.firstSync.promise,
232
            //make sure we're not in the middle of a sync
233
            this.syncPromise,
234
            //make sure all projects are activated
235
            ...this.projects.map(x => x.whenActivated())
166✔
236
        ]);
237
    }
238
    /**
239
     * Get a promise that resolves when the project manager is idle (no pending work)
240
     */
241
    public async onIdle() {
242
        await this.onInitialized();
35✔
243

244
        //There are race conditions where the fileChangesQueue will become idle, but that causes the documentManager
245
        //to start a new flush. So we must keep waiting until everything is idle
246
        while (!this.documentManager.isIdle || !this.fileChangesQueue.isIdle) {
35✔
247
            this.logger.debug('onIdle', { documentManagerIdle: this.documentManager.isIdle, fileChangesQueueIdle: this.fileChangesQueue.isIdle });
11✔
248

249
            await Promise.allSettled([
11✔
250
                //make sure all pending file changes have been flushed
251
                this.documentManager.onIdle(),
252
                //wait for the file changes queue to be idle
253
                this.fileChangesQueue.onIdle()
254
            ]);
255
        }
256

257
        this.logger.info('onIdle debug', { documentManagerIdle: this.documentManager.isIdle, fileChangesQueueIdle: this.fileChangesQueue.isIdle });
35✔
258
    }
259

260
    /**
261
     * Given a list of all desired projects, create any missing projects and destroy and projects that are no longer available
262
     * Treat workspaces that don't have a bsconfig.json as a project.
263
     * Handle situations where bsconfig.json files were added or removed (to elevate/lower workspaceFolder projects accordingly)
264
     * Leave existing projects alone if they are not affected by these changes
265
     * @param workspaceConfigs an array of workspaces
266
     */
267
    @TrackBusyStatus
268
    public async syncProjects(workspaceConfigs: WorkspaceConfig[], forceReload = false) {
1✔
269
        //if we're force reloading, destroy all projects and start fresh
270
        if (forceReload) {
66!
271
            this.logger.log('syncProjects: forceReload is true so removing all existing projects');
×
272
            for (const project of this.projects) {
×
273
                this.removeProject(project);
×
274
            }
275
        }
276
        this.logger.log('syncProjects', workspaceConfigs.map(x => x.workspaceFolder));
66✔
277

278
        this.syncPromise = (async () => {
66✔
279
            //build a list of unique projects across all workspace folders
280
            let projectConfigs = (await Promise.all(
66✔
281
                workspaceConfigs.map(async workspaceConfig => {
282
                    const projectPaths = await this.getProjectPaths(workspaceConfig);
65✔
283
                    return projectPaths.map(projectPath => ({
78✔
284
                        projectPath: s`${projectPath}`,
285
                        workspaceFolder: s`${workspaceConfig.workspaceFolder}`,
286
                        excludePatterns: workspaceConfig.excludePatterns,
287
                        enableThreading: workspaceConfig.enableThreading
288
                    }));
289
                })
290
            )).flat(1);
291

292
            //filter the project paths to only include those that are allowed by the path filterer
293
            projectConfigs = this.pathFilterer.filter(projectConfigs, x => x.projectPath);
66✔
294

295
            //delete projects not represented in the list
296
            for (const project of this.projects) {
66✔
297
                //we can't find this existing project in our new list, so scrap it
298
                if (!projectConfigs.find(x => x.projectPath === project.projectPath)) {
18✔
299
                    this.removeProject(project);
5✔
300
                }
301
            }
302

303
            // skip projects we already have (they're already loaded...no need to reload them)
304
            projectConfigs = projectConfigs.filter(x => {
66✔
305
                return !this.hasProject(x.projectPath);
78✔
306
            });
307

308
            //dedupe by project path
309
            projectConfigs = [
66✔
310
                ...projectConfigs.reduce(
311
                    (acc, x) => acc.set(x.projectPath, x),
70✔
312
                    new Map<string, typeof projectConfigs[0]>()
313
                ).values()
314
            ];
315

316
            //create missing projects
317
            await Promise.all(
66✔
318
                projectConfigs.map(async (config) => {
319
                    await this.createAndActivateProject(config);
69✔
320
                })
321
            );
322

323
            //mark that we've completed our first sync
324
            this.firstSync.tryResolve();
66✔
325
        })();
326

327
        //return the sync promise
328
        return this.syncPromise;
66✔
329
    }
330

331
    private fileChangesQueue = new ActionQueue({
89✔
332
        maxActionDuration: 45_000
333
    });
334

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

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

343
            return this._handleFileChanges(changes);
69✔
344
        }, changes);
345
    }
346

347
    /**
348
     * Handle when files or directories are added, changed, or deleted in the workspace.
349
     * This is safe to call any time. Changes will be queued and flushed at the correct times
350
     */
351
    private async _handleFileChanges(changes: FileChange[]) {
352
        //normalize srcPath for all changes
353
        for (const change of changes) {
69✔
354
            change.srcPath = util.standardizePath(change.srcPath);
78✔
355
        }
356

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

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

362
        //process all file changes in parallel
363
        await Promise.all(changes.map(async (change) => {
69✔
364
            await this.handleFileChange(change);
77✔
365
        }));
366
    }
367

368
    /**
369
     * Handle a single file change. If the file is a directory, this will recursively read all files in the directory and call `handleFileChanges` again
370
     */
371
    private async handleFileChange(change: FileChange) {
372
        if (change.type === FileChangeType.Deleted) {
81✔
373
            //mark this document or directory as deleted
374
            this.documentManager.delete(change.srcPath);
1✔
375

376
            //file added or changed
377
        } else {
378
            //if this is a new directory, read all files recursively and register those as file changes too
379
            if (util.isDirectorySync(change.srcPath)) {
80✔
380
                const files = await fastGlob('**/*', {
2✔
381
                    cwd: change.srcPath,
382
                    onlyFiles: true,
383
                    absolute: true
384
                });
385
                //pipe all files found recursively in the new directory through this same function so they can be processed correctly
386
                await Promise.all(files.map((srcPath) => {
2✔
387
                    return this.handleFileChange({
6✔
388
                        srcPath: util.standardizePath(srcPath),
389
                        type: FileChangeType.Changed,
390
                        allowStandaloneProject: change.allowStandaloneProject
391
                    });
392
                }));
393

394
                //this is a new file. set the file contents
395
            } else {
396
                this.documentManager.set({
78✔
397
                    srcPath: change.srcPath,
398
                    fileContents: change.fileContents,
399
                    allowStandaloneProject: change.allowStandaloneProject
400
                });
401
            }
402
        }
403

404
        //reload any projects whose bsconfig.json was changed
405
        const projectsToReload = this.projects.filter(project => {
81✔
406
            //this is a path to a bsconfig.json file
407
            if (project.bsconfigPath?.toLowerCase() === change.srcPath.toLowerCase()) {
95✔
408
                //fetch file contents if we don't already have them
409
                if (!change.fileContents) {
4!
410
                    try {
4✔
411
                        change.fileContents = fsExtra.readFileSync(project.bsconfigPath).toString();
4✔
412
                    } finally { }
413
                }
414
                ///the bsconfig contents have changed since we last saw it, so reload this project
415
                if (project.bsconfigFileContents !== change.fileContents) {
4✔
416
                    return true;
3✔
417
                }
418
            }
419
            return false;
92✔
420
        });
421

422
        if (projectsToReload.length > 0) {
81✔
423
            await Promise.all(
3✔
424
                projectsToReload.map(x => this.reloadProject(x))
3✔
425
            );
426
        }
427
    }
428

429
    /**
430
     * Handle when a file is closed in the editor (this mostly just handles removing standalone projects)
431
     */
432
    public async handleFileClose(event: { srcPath: string }) {
433
        this.logger.debug(`File was closed. ${event.srcPath}`);
1✔
434
        this.removeStandaloneProject(event.srcPath);
1✔
435
        //most other methods on this class are async, might as well make this one async too for consistency and future expansion
436
        await Promise.resolve();
1✔
437
    }
438

439
    /**
440
     * Given a project, forcibly reload it by removing it and re-adding it
441
     */
442
    private async reloadProject(project: LspProject) {
443
        this.logger.log('Reloading project', { projectPath: project.projectPath });
3✔
444

445
        this.removeProject(project);
3✔
446
        project = await this.createAndActivateProject(project.activateOptions);
3✔
447
    }
448

449
    /**
450
     * Get all the semantic tokens for the given file
451
     * @returns an array of semantic tokens
452
     */
453
    @TrackBusyStatus
454
    public async getSemanticTokens(options: { srcPath: string }) {
1✔
455
        //wait for all pending syncs to finish
456
        await this.onIdle();
1✔
457

458
        let result = await util.promiseRaceMatch(
1✔
459
            this.projects.map(x => x.getSemanticTokens(options)),
1✔
460
            //keep the first non-falsey result
461
            (result) => result?.length > 0
1!
462
        );
463
        return result;
1✔
464
    }
465

466
    /**
467
     * Get a string containing the transpiled contents of the file at the given path
468
     * @returns the transpiled contents of the file as a string
469
     */
470
    @TrackBusyStatus
471
    public async transpileFile(options: { srcPath: string }) {
1✔
472
        //wait for all pending syncs to finish
473
        await this.onIdle();
2✔
474

475
        let result = await util.promiseRaceMatch(
2✔
476
            this.projects.map(x => x.transpileFile(options)),
2✔
477
            //keep the first non-falsey result
478
            (result) => !!result
2✔
479
        );
480
        return result;
2✔
481
    }
482

483
    /**
484
     *  Get the completions for the given position in the file
485
     */
486
    @TrackBusyStatus
487
    public async getCompletions(options: { srcPath: string; position: Position; cancellationToken?: CancellationToken }): Promise<CompletionList> {
1✔
488
        await this.onIdle();
2✔
489

490
        //if the request has been cancelled since originally requested due to idle time being slow, skip the rest of the wor
491
        if (options?.cancellationToken?.isCancellationRequested) {
2!
492
            this.logger.log('ProjectManager getCompletions cancelled', options);
×
493
            return;
×
494
        }
495

496
        this.logger.log('ProjectManager getCompletions', options);
2✔
497
        //Ask every project for results, keep whichever one responds first that has a valid response
498
        let result = await util.promiseRaceMatch(
2✔
499
            this.projects.map(x => x.getCompletions(options)),
4✔
500
            //keep the first non-falsey result
501
            (result) => result?.items?.length > 0
2!
502
        );
503
        return result;
2✔
504
    }
505

506
    /**
507
     * Get the hover information for the given position in the file. If multiple projects have hover information, the projects will be raced and
508
     * the fastest result will be returned
509
     * @returns the hover information or undefined if no hover information was found
510
     */
511
    @TrackBusyStatus
512
    public async getHover(options: { srcPath: string; position: Position }): Promise<Hover> {
1✔
513
        //wait for all pending syncs to finish
514
        await this.onIdle();
1✔
515

516
        //Ask every project for hover info, keep whichever one responds first that has a valid response
517
        let hovers = await util.promiseRaceMatch(
1✔
518
            this.projects.map(x => x.getHover(options)),
1✔
519
            //keep the first set of non-empty results
520
            (result) => result?.length > 0
1!
521
        );
522

523
        let result = {
1✔
524
            contents: [],
525
            range: undefined as Range | undefined
526
        };
527

528
        //consolidate all hover results into a single hover
529
        for (const hover of hovers ?? []) {
1!
530
            if (typeof hover?.contents === 'string') {
2!
NEW
531
                result.contents.push(hover.contents);
×
532
            } else if (Array.isArray(hover?.contents)) {
2!
533
                result.contents.push(...hover.contents);
2✔
534
            }
535

536
            if (!result.range && hover.range) {
2✔
537
                result.range = hover.range;
1✔
538
            }
539
            result.range = util.createBoundingRange(result.range, hover.range);
2✔
540
        }
541

542
        //now only keep unique hovers
543
        result.contents = [...new Set(result.contents)];
1✔
544
        return result;
1✔
545
    }
546

547
    /**
548
     * Get the definition for the symbol at the given position in the file
549
     * @returns a list of locations where the symbol under the position is defined in the project
550
     */
551
    @TrackBusyStatus
552
    public async getDefinition(options: { srcPath: string; position: Position }): Promise<Location[]> {
1✔
553
        //wait for all pending syncs to finish
554
        await this.onIdle();
5✔
555

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

558
        //Ask every project for definition info, keep whichever one responds first that has a valid response
559
        let result = await util.promiseRaceMatch(
5✔
560
            this.projects.map(x => x.getDefinition(options)),
5✔
561
            //keep the first non-falsey result
562
            (result) => !!result
5✔
563
        );
564
        return result;
5✔
565
    }
566

567
    @TrackBusyStatus
568
    public async getSignatureHelp(options: { srcPath: string; position: Position }): Promise<SignatureHelp> {
1✔
569
        //wait for all pending syncs to finish
570
        await this.onIdle();
4✔
571

572
        //Ask every project for definition info, keep whichever one responds first that has a valid response
573
        let signatures = await util.promiseRaceMatch(
4✔
574
            this.projects.map(x => x.getSignatureHelp(options)),
4✔
575
            //keep the first non-falsey result
576
            (result) => !!result
4✔
577
        );
578

579
        if (signatures?.length > 0) {
4!
580
            const activeSignature = signatures.length > 0 ? 0 : undefined;
3!
581

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

584
            let result: SignatureHelp = {
3✔
585
                signatures: signatures.map((s) => s.signature),
3✔
586
                activeSignature: activeSignature,
587
                activeParameter: activeParameter
588
            };
589
            return result;
3✔
590
        }
591
    }
592

593
    @TrackBusyStatus
594
    public async getDocumentSymbol(options: { srcPath: string }): Promise<DocumentSymbol[]> {
1✔
595
        //wait for all pending syncs to finish
596
        await this.onIdle();
6✔
597

598
        //Ask every project for definition info, keep whichever one responds first that has a valid response
599
        let result = await util.promiseRaceMatch(
6✔
600
            this.projects.map(x => x.getDocumentSymbol(options)),
6✔
601
            //keep the first non-falsey result
602
            (result) => !!result
6✔
603
        );
604
        return result;
6✔
605
    }
606

607
    @TrackBusyStatus
608
    public async getWorkspaceSymbol(): Promise<WorkspaceSymbol[]> {
1✔
609
        //wait for all pending syncs to finish
610
        await this.onIdle();
5✔
611

612
        //Ask every project for definition info, keep whichever one responds first that has a valid response
613
        let responses = await Promise.allSettled(
5✔
614
            this.projects.map(x => x.getWorkspaceSymbol())
5✔
615
        );
616
        let results = responses
5✔
617
            //keep all symbol results
618
            .map((x) => {
619
                return x.status === 'fulfilled' ? x.value : [];
5!
620
            })
621
            //flatten the array
622
            .flat()
623
            //throw out nulls
624
            .filter(x => !!x);
24✔
625

626
        // Remove duplicates
627
        const allSymbols = Object.values(
5✔
628
            results.reduce((map, symbol) => {
629
                const key = symbol.location.uri + symbol.name;
24✔
630
                map[key] = symbol;
24✔
631
                return map;
24✔
632
            }, {})
633
        );
634

635
        return allSymbols as SymbolInformation[];
5✔
636
    }
637

638
    @TrackBusyStatus
639
    public async getReferences(options: { srcPath: string; position: Position }): Promise<Location[]> {
1✔
640
        //wait for all pending syncs to finish
641
        await this.onIdle();
3✔
642

643
        //Ask every project for definition info, keep whichever one responds first that has a valid response
644
        let result = await util.promiseRaceMatch(
3✔
645
            this.projects.map(x => x.getReferences(options)),
3✔
646
            //keep the first non-falsey result
647
            (result) => !!result
3✔
648
        );
649
        return result ?? [];
3!
650
    }
651

652
    @TrackBusyStatus
653
    public async getCodeActions(options: { srcPath: string; range: Range }) {
1✔
654
        //wait for all pending syncs to finish
655
        await this.onIdle();
×
656

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

666
    /**
667
     * Scan a given workspace for all `bsconfig.json` files. If at least one is found, then only folders who have bsconfig.json are returned.
668
     * If none are found, then the workspaceFolder itself is treated as a project
669
     */
670
    private async getProjectPaths(workspaceConfig: WorkspaceConfig) {
671
        //get the list of exclude patterns, and negate them (so they actually work like excludes)
672
        const excludePatterns = (workspaceConfig.excludePatterns ?? []).map(x => s`!${x}`);
65✔
673
        let files = await rokuDeploy.getFilePaths([
65✔
674
            '**/bsconfig.json',
675
            //exclude all files found in `files.exclude`
676
            ...excludePatterns
677
        ], workspaceConfig.workspaceFolder);
678

679
        //filter the files to only include those that are allowed by the path filterer
680
        files = this.pathFilterer.filter(files, x => x.src);
65✔
681

682
        //if we found at least one bsconfig.json, then ALL projects must have a bsconfig.json.
683
        if (files.length > 0) {
65✔
684
            return files.map(file => s`${path.dirname(file.src)}`);
38✔
685
        }
686

687
        //look for roku project folders
688
        let rokuLikeDirs = (await Promise.all(
39✔
689
            //find all folders containing a `manifest` file
690
            (await rokuDeploy.getFilePaths([
691
                '**/manifest',
692
                ...excludePatterns
693

694
                //is there at least one .bs|.brs file under the `/source` folder?
695
            ], workspaceConfig.workspaceFolder)).map(async manifestEntry => {
696
                const manifestDir = path.dirname(manifestEntry.src);
5✔
697
                const files = await rokuDeploy.getFilePaths([
5✔
698
                    'source/**/*.{brs,bs}',
699
                    ...excludePatterns
700
                ], manifestDir);
701
                if (files.length > 0) {
5✔
702
                    return manifestDir;
3✔
703
                }
704
            })
705
            //throw out nulls
706
        )).filter(x => !!x);
5✔
707

708
        //throw out any directories that are not allowed by the path filterer
709
        rokuLikeDirs = this.pathFilterer.filter(rokuLikeDirs, srcPath => srcPath);
39✔
710

711
        if (rokuLikeDirs.length > 0) {
39✔
712
            return rokuLikeDirs;
2✔
713
        }
714

715
        //treat the workspace folder as a brightscript project itself
716
        return [workspaceConfig.workspaceFolder];
37✔
717
    }
718

719
    /**
720
     * Returns true if we have this project, or false if we don't
721
     * @param projectPath path to the project
722
     * @returns true if the project exists, or false if it doesn't
723
     */
724
    private hasProject(projectPath: string) {
725
        return !!this.getProject(projectPath);
232✔
726
    }
727

728
    /**
729
     * Get a project with the specified path
730
     * @param param path to the project or an obj that has `projectPath` prop
731
     * @returns a project, or undefined if no project was found
732
     */
733
    private getProject(param: string | { projectPath: string }) {
734
        const projectPath = util.standardizePath(
234✔
735
            (typeof param === 'string') ? param : param.projectPath
234✔
736
        );
737
        return this.projects.find(x => x.projectPath === projectPath);
234✔
738
    }
739

740
    /**
741
     * Remove a project from the language server
742
     */
743
    private removeProject(project: LspProject) {
744
        const idx = this.projects.findIndex(x => x.projectPath === project?.projectPath);
14✔
745
        if (idx > -1) {
14✔
746
            this.logger.log('Removing project', { projectPath: project.projectPath, projectNumber: project.projectNumber });
11✔
747
            this.projects.splice(idx, 1);
11✔
748
        }
749
        //anytime we remove a project, we should emit an event that clears all of its diagnostics
750
        this.emit('diagnostics', { project: project, diagnostics: [] });
14✔
751
        project?.dispose();
14✔
752
        this.busyStatusTracker.endAllRunsForScope(project);
14✔
753
    }
754

755
    /**
756
     * A unique project counter to help distinguish log entries in lsp mode
757
     */
758
    private static projectNumberSequence = 0;
1✔
759

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

762
    /**
763
     * Get a projectNumber for a given config. Try to reuse project numbers when we've seen this project before
764
     *  - If the config already has one, use that.
765
     *  - If we've already seen this config before, use the same project number as before
766
     */
767
    private getProjectNumber(config: ProjectConfig) {
768
        if (config.projectNumber !== undefined) {
79✔
769
            return config.projectNumber;
9✔
770
        }
771
        return ProjectManager.projectNumberCache.getOrAdd(`${s(config.projectPath)}-${s(config.workspaceFolder)}-${config.bsconfigPath}`, () => {
70✔
772
            return ProjectManager.projectNumberSequence++;
13✔
773
        });
774
    }
775

776
    /**
777
     * Constructs a project for the given config. Just makes the project, doesn't activate it
778
     * @returns a new project, or the existing project if one already exists with this config info
779
     */
780
    private constructProject(config: ProjectConfig): LspProject {
781
        //skip this project if we already have it
782
        if (this.hasProject(config.projectPath)) {
79!
783
            return this.getProject(config.projectPath);
×
784
        }
785

786
        config.projectNumber = this.getProjectNumber(config);
79✔
787
        const projectIdentifier = `prj${config.projectNumber}`;
79✔
788

789
        let project: LspProject = config.enableThreading
79✔
790
            ? new WorkerThreadProject({
79✔
791
                logger: this.logger.createLogger(),
792
                projectIdentifier: projectIdentifier
793
            })
794
            : new Project({
795
                logger: this.logger.createLogger(),
796
                projectIdentifier: projectIdentifier
797
            });
798

799
        this.logger.log(`Created project #${config.projectNumber} for: "${config.projectPath}" (${config.enableThreading ? 'worker thread' : 'main thread'})`);
79✔
800

801
        this.projects.push(project);
79✔
802

803
        //pipe all project-specific events through our emitter, and include the project reference
804
        project.on('all', (eventName, data) => {
79✔
805
            this.emit(eventName as any, {
278✔
806
                ...data,
807
                project: project
808
            } as any);
809
        });
810
        return project;
79✔
811
    }
812

813
    /**
814
     * Constructs a project for the given config
815
     * @returns a new project, or the existing project if one already exists with this config info
816
     */
817
    @TrackBusyStatus
818
    private async createAndActivateProject(config: ProjectConfig): Promise<LspProject> {
1✔
819
        //skip this project if we already have it
820
        if (this.hasProject(config.projectPath)) {
75✔
821
            return this.getProject(config.projectPath);
1✔
822
        }
823
        const project = this.constructProject(config);
74✔
824
        await this.activateProject(project, config);
74✔
825
        return project;
73✔
826
    }
827

828
    @TrackBusyStatus
829
    private async activateProject(project: LspProject, config: ProjectConfig) {
1✔
830
        this.logger.debug('Activating project', project.projectIdentifier, {
79✔
831
            projectPath: config?.projectPath,
237!
832
            bsconfigPath: config.bsconfigPath
833
        });
834
        await project.activate(config);
79✔
835

836
        //send an event to indicate that this project has been activated
837
        this.emit('project-activate', { project: project });
78✔
838

839
        //register this project's list of files with the path filterer
840
        const unregister = this.pathFilterer.registerIncludeList(project.rootDir, project.filePatterns);
78✔
841
        project.disposables.push({ dispose: unregister });
78✔
842
    }
843

844
    public on(eventName: 'validate-begin', handler: (data: { project: LspProject }) => MaybePromise<void>);
845
    public on(eventName: 'validate-end', handler: (data: { project: LspProject }) => MaybePromise<void>);
846
    public on(eventName: 'critical-failure', handler: (data: { project: LspProject; message: string }) => MaybePromise<void>);
847
    public on(eventName: 'project-activate', handler: (data: { project: LspProject }) => MaybePromise<void>);
848
    public on(eventName: 'diagnostics', handler: (data: { project: LspProject; diagnostics: LspDiagnostic[] }) => MaybePromise<void>);
849
    public on(eventName: string, handler: (payload: any) => MaybePromise<void>) {
850
        this.emitter.on(eventName, handler as any);
324✔
851
        return () => {
324✔
852
            this.emitter.removeListener(eventName, handler as any);
1✔
853
        };
854
    }
855

856
    private emit(eventName: 'validate-begin', data: { project: LspProject });
857
    private emit(eventName: 'validate-end', data: { project: LspProject });
858
    private emit(eventName: 'critical-failure', data: { project: LspProject; message: string });
859
    private emit(eventName: 'project-activate', data: { project: LspProject });
860
    private emit(eventName: 'diagnostics', data: { project: LspProject; diagnostics: LspDiagnostic[] });
861
    private async emit(eventName: string, data?) {
862
        //emit these events on next tick, otherwise they will be processed immediately which could cause issues
863
        await util.sleep(0);
375✔
864
        this.emitter.emit(eventName, data);
375✔
865
    }
866
    private emitter = new EventEmitter();
89✔
867

868
    public dispose() {
869
        this.emitter.removeAllListeners();
89✔
870
        for (const project of this.projects) {
89✔
871
            project?.dispose?.();
69!
872
        }
873
    }
874
}
875

876
export interface WorkspaceConfig {
877
    /**
878
     * Absolute path to the folder where the workspace resides
879
     */
880
    workspaceFolder: string;
881
    /**
882
     * A list of glob patterns used to _exclude_ files from various bsconfig searches
883
     */
884
    excludePatterns?: string[];
885
    /**
886
     * Path to a bsconfig that should be used instead of the auto-discovery algorithm. If this is present, no bsconfig discovery should be used. and an error should be emitted if this file is missing
887
     */
888
    bsconfigPath?: string;
889
    /**
890
     * Should the projects in this workspace be run in their own dedicated worker threads, or all run on the main thread
891
     */
892
    enableThreading?: boolean;
893
}
894

895
interface StandaloneProject extends LspProject {
896
    /**
897
     * The path to the file that this project represents
898
     */
899
    srcPath: string;
900
}
901

902
/**
903
 * An annotation used to wrap the method in a busyStatus tracking call
904
 */
905
function TrackBusyStatus(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
906
    let originalMethod = descriptor.value;
14✔
907

908
    //wrapping the original method
909
    descriptor.value = function value(this: ProjectManager, ...args: any[]) {
14✔
910
        return this.busyStatusTracker.run(() => {
290✔
911
            return originalMethod.apply(this, args);
290✔
912
        }, originalMethod.name);
913
    };
914
}
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