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

rokucommunity / brighterscript / #15129

28 Jan 2026 03:47PM UTC coverage: 87.193% (-1.8%) from 88.969%
#15129

push

web-flow
Merge aa579b555 into 610607efc

14637 of 17741 branches covered (82.5%)

Branch coverage included in aggregate %.

69 of 71 new or added lines in 11 files covered. (97.18%)

825 existing lines in 47 files now uncovered.

15395 of 16702 relevant lines covered (92.17%)

24785.89 hits per line

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

87.37
/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, 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();
109!
39
        this.pathFilterer = options?.pathFilterer ?? new PathFilterer({ logger: options?.logger });
109!
40
        this.documentManager = new DocumentManager({
109✔
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) => {
109✔
48
            this.busyStatusTracker.beginScopedRun(event.project, `validate-project`);
120✔
49
        });
50
        this.on('validate-end', (event) => {
109✔
51
            void this.busyStatusTracker.endScopedRun(event.project, `validate-project`);
117✔
52
        });
53
    }
54

55
    private pathFilterer: PathFilterer;
56

57
    private logger: Logger;
58

59
    /**
60
     * Collection of all projects
61
     */
62
    public projects: LspProject[] = [];
109✔
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>();
109✔
69

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

73
    public busyStatusTracker = new BusyStatusTracker<LspProject>();
109✔
74

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

186
        const projectNumber = ProjectManager.projectNumberSequence++;
5✔
187
        const rootDir = path.join(__dirname, `standalone-project-${projectNumber}`);
5✔
188
        const projectConfig: ProjectConfig = {
5✔
189
            //these folders don't matter for standalone projects
190
            workspaceFolder: rootDir,
191
            projectDir: rootDir,
192
            //there's no bsconfig.json for standalone projects, so projectKey is the same as the dir
193
            projectKey: rootDir,
194
            bsconfigPath: undefined,
195
            enableThreading: false,
196
            projectNumber: projectNumber,
197
            files: [{
198
                src: srcPath,
199
                dest: 'source/standalone.brs'
200
            }]
201
        };
202

203
        const project = this.constructProject(projectConfig) as StandaloneProject;
5✔
204
        project.srcPath = srcPath;
5✔
205
        project.isStandaloneProject = true;
5✔
206

207
        this.standaloneProjects.set(srcPath, project);
5✔
208
        await this.activateProject(project, projectConfig);
5✔
209
    }
210

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

223
    /**
224
     * A promise that's set when a sync starts, and resolved when the sync is complete
225
     */
226
    private syncPromise: Promise<void> | undefined;
227
    private firstSync = new Deferred();
109✔
228

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

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

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

261
        this.logger.info('onIdle debug', { documentManagerIdle: this.documentManager.isIdle, fileChangesQueueIdle: this.fileChangesQueue.isIdle });
35✔
262
    }
263

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

282
        this.syncPromise = (async () => {
77✔
283
            //build a list of unique projects across all workspace folders
284
            let projectConfigs = (await Promise.all(
77✔
285
                workspaceConfigs.map(async workspaceConfig => {
286
                    const discoveredProjects = await this.discoverProjectsForWorkspace(workspaceConfig);
76✔
287
                    return discoveredProjects.map<ProjectConfig>(discoveredProject => ({
105✔
288
                        name: discoveredProject?.name,
315!
289
                        projectKey: s`${discoveredProject.bsconfigPath ?? discoveredProject.dir}`,
315✔
290
                        projectDir: s`${discoveredProject.dir}`,
291
                        bsconfigPath: discoveredProject?.bsconfigPath,
315!
292
                        workspaceFolder: s`${workspaceConfig.workspaceFolder}`,
293
                        excludePatterns: workspaceConfig.excludePatterns,
294
                        enableThreading: workspaceConfig.languageServer.enableThreading
295
                    }));
296
                })
297
            )).flat(1);
298

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

303
            //delete projects not represented in the list
304
            for (const project of this.projects) {
77✔
305
                //we can't find this existing project in our new list, so scrap it
306
                if (!projectConfigs.find(x => x.projectKey === project.projectKey)) {
17✔
307
                    this.removeProject(project);
5✔
308
                }
309
            }
310

311
            // skip projects we already have (they're already loaded...no need to reload them)
312
            projectConfigs = projectConfigs.filter(x => {
77✔
313
                return !this.hasProject(x);
105✔
314
            });
315

316
            //dedupe by projectKey
317
            projectConfigs = [
77✔
318
                ...projectConfigs.reduce(
319
                    (acc, x) => acc.set(x.projectKey, x),
97✔
320
                    new Map<string, typeof projectConfigs[0]>()
321
                ).values()
322
            ];
323

324
            //create missing projects
325
            await Promise.all(
77✔
326
                projectConfigs.map(async (config) => {
327
                    await this.createAndActivateProject(config);
96✔
328
                })
329
            );
330

331
            //mark that we've completed our first sync
332
            this.firstSync.tryResolve();
77✔
333
        })();
334

335
        //return the sync promise
336
        return this.syncPromise;
77✔
337
    }
338

339
    private fileChangesQueue = new ActionQueue({
109✔
340
        maxActionDuration: 45_000
341
    });
342

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

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

351
            return this._handleFileChanges(changes);
69✔
352
        }, changes);
353
    }
354

355
    /**
356
     * Handle when files or directories are added, changed, or deleted in the workspace.
357
     * This is safe to call any time. Changes will be queued and flushed at the correct times
358
     */
359
    private async _handleFileChanges(changes: FileChange[]) {
360
        //normalize srcPath for all changes
361
        for (const change of changes) {
69✔
362
            change.srcPath = util.standardizePath(change.srcPath);
78✔
363
        }
364

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

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

370
        //process all file changes in parallel
371
        await Promise.all(changes.map(async (change) => {
69✔
372
            await this.handleFileChange(change);
77✔
373
        }));
374
    }
375

376
    /**
377
     * Handle a single file change. If the file is a directory, this will recursively read all files in the directory and call `handleFileChanges` again
378
     */
379
    private async handleFileChange(change: FileChange) {
380
        if (change.type === FileChangeType.Deleted) {
81✔
381
            //mark this document or directory as deleted
382
            this.documentManager.delete(change.srcPath);
1✔
383

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

402
                //this is a new file. set the file contents
403
            } else {
404
                this.documentManager.set({
78✔
405
                    srcPath: change.srcPath,
406
                    fileContents: change.fileContents,
407
                    allowStandaloneProject: change.allowStandaloneProject
408
                });
409
            }
410
        }
411

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

430
        if (projectsToReload.length > 0) {
81✔
431
            await Promise.all(
3✔
432
                projectsToReload.map(x => this.reloadProject(x))
3✔
433
            );
434
        }
435
    }
436

437
    /**
438
     * Handle when a file is closed in the editor (this mostly just handles removing standalone projects)
439
     */
440
    public async handleFileClose(event: { srcPath: string }) {
441
        this.logger.debug(`File was closed. ${event.srcPath}`);
1✔
442
        this.removeStandaloneProject(event.srcPath);
1✔
443
        //most other methods on this class are async, might as well make this one async too for consistency and future expansion
444
        await Promise.resolve();
1✔
445
    }
446

447
    /**
448
     * Given a project, forcibly reload it by removing it and re-adding it
449
     */
450
    private async reloadProject(project: LspProject) {
451
        this.logger.log('Reloading project', { projectPath: project.projectKey });
3✔
452

453
        this.removeProject(project);
3✔
454
        project = await this.createAndActivateProject(project.activateOptions);
3✔
455
    }
456

457
    /**
458
     * Get all the semantic tokens for the given file
459
     * @returns an array of semantic tokens
460
     */
461
    @TrackBusyStatus
462
    public async getSemanticTokens(options: { srcPath: string }) {
1✔
463
        //wait for all pending syncs to finish
464
        await this.onIdle();
1✔
465

466
        let result = await util.promiseRaceMatch(
1✔
467
            this.projects.map(x => x.getSemanticTokens(options)),
1✔
468
            //keep the first non-falsey result
469
            (result) => result?.length > 0
1!
470
        );
471
        return result;
1✔
472
    }
473

474
    /**
475
     * Get a string containing the transpiled contents of the file at the given path
476
     * @returns the transpiled contents of the file as a string
477
     */
478
    @TrackBusyStatus
479
    public async transpileFile(options: { srcPath: string }) {
1✔
480
        //wait for all pending syncs to finish
481
        await this.onIdle();
2✔
482

483
        let result = await util.promiseRaceMatch(
2✔
484
            this.projects.map(x => x.transpileFile(options)),
2✔
485
            //keep the first non-falsey result
486
            (result) => !!result
2✔
487
        );
488
        return result;
2✔
489
    }
490

491
    /**
492
     *  Get the completions for the given position in the file
493
     */
494
    @TrackBusyStatus
495
    public async getCompletions(options: { srcPath: string; position: Position; cancellationToken?: CancellationToken }): Promise<CompletionList> {
1✔
496
        await this.onIdle();
2✔
497

498
        //if the request has been cancelled since originally requested due to idle time being slow, skip the rest of the wor
499
        if (options?.cancellationToken?.isCancellationRequested) {
2!
500
            this.logger.debug('ProjectManager getCompletions cancelled', options);
×
501
            return;
×
502
        }
503

504
        this.logger.debug('ProjectManager getCompletions', options);
2✔
505
        //Ask every project for results, keep whichever one responds first that has a valid response
506
        let result = await util.promiseRaceMatch(
2✔
507
            this.projects.map(x => x.getCompletions(options)),
4✔
508
            //keep the first non-falsey result
509
            (result) => result?.items?.length > 0
2!
510
        );
511
        return result;
2✔
512
    }
513

514
    /**
515
     * Get the hover information for the given position in the file. If multiple projects have hover information, the projects will be raced and
516
     * the fastest result will be returned
517
     * @returns the hover information or undefined if no hover information was found
518
     */
519
    @TrackBusyStatus
520
    public async getHover(options: { srcPath: string; position: Position }): Promise<Hover> {
1✔
521
        //wait for all pending syncs to finish
522
        await this.onIdle();
1✔
523

524
        //Ask every project for hover info, keep whichever one responds first that has a valid response
525
        let hovers = await util.promiseRaceMatch(
1✔
526
            this.projects.map(x => x.getHover(options)),
1✔
527
            //keep the first set of non-empty results
528
            (result) => result?.length > 0
1!
529
        );
530

531
        let result = {
1✔
532
            contents: [],
533
            range: undefined as Range | undefined
534
        };
535

536
        //consolidate all hover results into a single hover
537
        for (const hover of hovers ?? []) {
1!
538
            if (typeof hover?.contents === 'string') {
2!
UNCOV
539
                result.contents.push(hover.contents);
×
540
            } else if (Array.isArray(hover?.contents)) {
2!
541
                result.contents.push(...hover.contents);
2✔
542
            }
543

544
            if (!result.range && hover.range) {
2✔
545
                result.range = hover.range;
1✔
546
            }
547
            result.range = util.createBoundingRange(result.range, hover.range);
2✔
548
        }
549

550
        //now only keep unique hovers
551
        result.contents = [...new Set(result.contents)];
1✔
552
        return result;
1✔
553
    }
554

555
    /**
556
     * Get the definition for the symbol at the given position in the file
557
     * @returns a list of locations where the symbol under the position is defined in the project
558
     */
559
    @TrackBusyStatus
560
    public async getDefinition(options: { srcPath: string; position: Position }): Promise<Location[]> {
1✔
561
        //wait for all pending syncs to finish
562
        await this.onIdle();
5✔
563

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

566
        //Ask every project for definition info, keep whichever one responds first that has a valid response
567
        let result = await util.promiseRaceMatch(
5✔
568
            this.projects.map(x => x.getDefinition(options)),
5✔
569
            //keep the first non-falsey result
570
            (result) => !!result
5✔
571
        );
572
        return result;
5✔
573
    }
574

575
    @TrackBusyStatus
576
    public async getSignatureHelp(options: { srcPath: string; position: Position }): Promise<SignatureHelp> {
1✔
577
        //wait for all pending syncs to finish
578
        await this.onIdle();
4✔
579

580
        //Ask every project for definition info, keep whichever one responds first that has a valid response
581
        let signatures = await util.promiseRaceMatch(
4✔
582
            this.projects.map(x => x.getSignatureHelp(options)),
4✔
583
            //keep the first non-falsey result
584
            (result) => !!result
4✔
585
        );
586

587
        if (signatures?.length > 0) {
4!
588
            const activeSignature = signatures.length > 0 ? 0 : undefined;
3!
589

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

592
            let result: SignatureHelp = {
3✔
593
                signatures: signatures.map((s) => s.signature),
3✔
594
                activeSignature: activeSignature,
595
                activeParameter: activeParameter
596
            };
597
            return result;
3✔
598
        }
599
    }
600

601
    @TrackBusyStatus
602
    public async getDocumentSymbol(options: { srcPath: string }): Promise<DocumentSymbol[]> {
1✔
603
        //wait for all pending syncs to finish
604
        await this.onIdle();
6✔
605

606
        //Ask every project for definition info, keep whichever one responds first that has a valid response
607
        let result = await util.promiseRaceMatch(
6✔
608
            this.projects.map(x => x.getDocumentSymbol(options)),
6✔
609
            //keep the first non-falsey result
610
            (result) => !!result
6✔
611
        );
612
        return result;
6✔
613
    }
614

615
    @TrackBusyStatus
616
    public async getWorkspaceSymbol(): Promise<WorkspaceSymbol[]> {
1✔
617
        //wait for all pending syncs to finish
618
        await this.onIdle();
5✔
619

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

634
        // Remove duplicates
635
        const allSymbols = Object.values(
5✔
636
            results.reduce((map, symbol) => {
637
                const key = symbol.location.uri + symbol.name;
24✔
638
                map[key] = symbol;
24✔
639
                return map;
24✔
640
            }, {})
641
        );
642

643
        return allSymbols as SymbolInformation[];
5✔
644
    }
645

646
    @TrackBusyStatus
647
    public async getReferences(options: { srcPath: string; position: Position }): Promise<Location[]> {
1✔
648
        //wait for all pending syncs to finish
649
        await this.onIdle();
3✔
650

651
        //Ask every project for definition info, keep whichever one responds first that has a valid response
652
        let result = await util.promiseRaceMatch(
3✔
653
            this.projects.map(x => x.getReferences(options)),
3✔
654
            //keep the first non-falsey result
655
            (result) => !!result
3✔
656
        );
657
        return result ?? [];
3!
658
    }
659

660
    @TrackBusyStatus
661
    public async getCodeActions(options: { srcPath: string; range: Range }) {
1✔
662
        //wait for all pending syncs to finish
663
        await this.onIdle();
×
664

665
        //Ask every project for definition info, keep whichever one responds first that has a valid response
UNCOV
666
        let result = await util.promiseRaceMatch(
×
667
            this.projects.map(x => x.getCodeActions(options)),
×
668
            //keep the first non-falsey result
UNCOV
669
            (result) => !!result
×
670
        );
UNCOV
671
        return result;
×
672
    }
673

674
    /**
675
     * Scan a given workspace for all `bsconfig.json` files. If at least one is found, then only folders who have bsconfig.json are returned.
676
     * If none are found, then the workspaceFolder itself is treated as a project
677
     */
678
    private async discoverProjectsForWorkspace(workspaceConfig: WorkspaceConfig): Promise<DiscoveredProject[]> {
679
        //config may provide a list of project paths. If we have these, no other discovery is permitted
680
        if (Array.isArray(workspaceConfig.projects) && workspaceConfig.projects.length > 0) {
76✔
681
            this.logger.debug(`Using project paths from workspace config`, workspaceConfig.projects);
3✔
682
            const projectConfigs = workspaceConfig.projects.reduce<DiscoveredProject[]>((acc, project) => {
3✔
683
                //skip this project if it's disabled or we don't have a path
684
                if (project.disabled || !project.path) {
5!
UNCOV
685
                    return acc;
×
686
                }
687
                //ensure the project path is absolute
688
                if (!path.isAbsolute(project.path)) {
5!
UNCOV
689
                    project.path = path.resolve(workspaceConfig.workspaceFolder, project.path);
×
690
                }
691

692
                //skip this project if the path does't exist
693
                if (!fsExtra.existsSync(project.path)) {
5!
UNCOV
694
                    return acc;
×
695
                }
696

697
                //if the project is a directory
698
                if (fsExtra.statSync(project.path).isDirectory()) {
5✔
699
                    acc.push({
2✔
700
                        name: project.name,
701
                        bsconfigPath: undefined,
702
                        dir: project.path
703
                    });
704
                    //it's a path to a file (hopefully bsconfig.json)
705
                } else {
706
                    acc.push({
3✔
707
                        name: project.name,
708
                        dir: path.dirname(project.path),
709
                        bsconfigPath: project.path
710
                    });
711
                }
712
                return acc;
5✔
713
            }, []);
714

715
            //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
716
            //disabled all their projects on purpose
717
            if (projectConfigs.length === 0) {
3!
UNCOV
718
                this.logger.warn(`No valid project paths found in workspace config`, JSON.stringify(workspaceConfig.projects, null, 4));
×
719
            }
720
            return projectConfigs;
3✔
721
        }
722

723
        //automatic discovery disabled?
724
        if (!workspaceConfig.languageServer.enableProjectDiscovery) {
73✔
725
            return [{
2✔
726
                dir: workspaceConfig.workspaceFolder
727
            }];
728
        }
729

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

733
        let files = await fastGlob(['**/bsconfig.json', ...excludePatterns], {
71✔
734
            cwd: workspaceConfig.workspaceFolder,
735
            followSymbolicLinks: false,
736
            absolute: true,
737
            onlyFiles: true,
738
            deep: workspaceConfig.languageServer.projectDiscoveryMaxDepth ?? 15
213✔
739
        });
740

741
        //filter the files to only include those that are allowed by the path filterer
742
        files = this.pathFilterer.filter(files);
71✔
743

744
        //if we found at least one bsconfig.json, then ALL projects must have a bsconfig.json.
745
        if (files.length > 0) {
71✔
746
            return files.map(file => ({
53✔
747
                dir: s`${path.dirname(file)}`,
748
                bsconfigPath: s`${file}`
749
            }));
750
        }
751

752
        //look for roku project folders
753
        let rokuLikeDirs = (await Promise.all(
40✔
754
            //find all folders containing a `manifest` file
755
            (await fastGlob(['**/manifest', ...excludePatterns], {
756
                cwd: workspaceConfig.workspaceFolder,
757
                followSymbolicLinks: false,
758
                absolute: true,
759
                onlyFiles: true,
760
                deep: workspaceConfig.languageServer.projectDiscoveryMaxDepth ?? 15
120✔
761
            })).map(async manifestEntry => {
762
                const manifestDir = path.dirname(manifestEntry);
11✔
763
                //TODO validate that manifest is a Roku manifest
764
                const files = await rokuDeploy.getFilePaths([
11✔
765
                    'source/**/*.{brs,bs}',
766
                    ...excludePatterns
767
                ], manifestDir);
768
                if (files.length > 0) {
11✔
769
                    return s`${manifestDir}`;
9✔
770
                }
771
            })
772
            //throw out nulls
773
        )).filter(x => !!x);
11✔
774

775
        //throw out any directories that are not allowed by the path filterer
776
        rokuLikeDirs = this.pathFilterer.filter(rokuLikeDirs, srcPath => srcPath);
40✔
777

778
        if (rokuLikeDirs.length > 0) {
40✔
779
            return rokuLikeDirs.map(file => ({
9✔
780
                dir: file
781
            }));
782
        }
783

784
        //treat the workspace folder as a brightscript project itself
785
        return [{
36✔
786
            dir: workspaceConfig.workspaceFolder
787
        }];
788
    }
789

790
    /**
791
     * Returns true if we have this project, or false if we don't
792
     * @returns true if the project exists, or false if it doesn't
793
     */
794
    private hasProject(config: Partial<ProjectConfig>) {
795
        return !!this.getProject(config);
313✔
796
    }
797

798
    /**
799
     * Get a project with the specified path
800
     * @param param path to the project or an obj that has `projectPath` prop
801
     * @returns a project, or undefined if no project was found
802
     */
803
    private getProject(param: string | Partial<ProjectConfig>) {
804
        const projectKey = util.standardizePath(
315✔
805
            (typeof param === 'string') ? param : (param?.projectKey ?? param?.bsconfigPath ?? param?.projectDir)
3,141!
806
        );
807
        if (!projectKey) {
315!
UNCOV
808
            return;
×
809
        }
810
        return this.projects.find(x => x.projectKey === projectKey);
315✔
811
    }
812

813
    /**
814
     * Remove a project from the language server
815
     */
816
    private removeProject(project: LspProject) {
817
        const idx = this.projects.findIndex(x => x.projectKey === project?.projectKey);
14✔
818
        if (idx > -1) {
14✔
819
            this.logger.log('Removing project', { projectKey: project.projectKey, projectNumber: project.projectNumber });
11✔
820
            this.projects.splice(idx, 1);
11✔
821
        }
822
        //anytime we remove a project, we should emit an event that clears all of its diagnostics
823
        this.emit('diagnostics', { project: project, diagnostics: [] });
14✔
824
        project?.dispose();
14✔
825
        this.busyStatusTracker.endAllRunsForScope(project);
14✔
826
    }
827

828
    /**
829
     * A unique project counter to help distinguish log entries in lsp mode
830
     */
831
    private static projectNumberSequence = 0;
1✔
832

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

835
    /**
836
     * Get a projectNumber for a given config. Try to reuse project numbers when we've seen this project before
837
     *  - If the config already has one, use that.
838
     *  - If we've already seen this config before, use the same project number as before
839
     */
840
    private getProjectNumber(config: ProjectConfig) {
841
        if (config.projectNumber !== undefined) {
106✔
842
            return config.projectNumber;
9✔
843
        }
844
        const key = s`${config.projectKey}` + '-' + s`${config.workspaceFolder}` + '-' + s`${config.bsconfigPath}`;
97✔
845
        return ProjectManager.projectNumberCache.getOrAdd(key, () => {
97✔
846
            return ProjectManager.projectNumberSequence++;
28✔
847
        });
848
    }
849

850
    /**
851
     * Constructs a project for the given config. Just makes the project, doesn't activate it
852
     * @returns a new project, or the existing project if one already exists with this config info
853
     */
854
    private constructProject(config: ProjectConfig): LspProject {
855
        //skip this project if we already have it
856
        if (this.hasProject(config)) {
106!
UNCOV
857
            return this.getProject(config);
×
858
        }
859

860
        config.projectNumber = this.getProjectNumber(config);
106✔
861

862
        let project: LspProject = config.enableThreading
106✔
863
            ? new WorkerThreadProject({
106✔
864
                logger: this.logger.createLogger()
865
            })
866
            : new Project({
867
                logger: this.logger.createLogger()
868
            });
869

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

872
        this.projects.push(project);
106✔
873

874
        //pipe all project-specific events through our emitter, and include the project reference
875
        project.on('all', (eventName, data) => {
106✔
876
            this.emit(eventName as any, {
356✔
877
                ...data,
878
                project: project
879
            } as any);
880
        });
881
        return project;
106✔
882
    }
883

884
    /**
885
     * Constructs a project for the given config
886
     * @returns a new project, or the existing project if one already exists with this config info
887
     */
888
    @TrackBusyStatus
889
    private async createAndActivateProject(config: ProjectConfig): Promise<LspProject> {
1✔
890
        //skip this project if we already have it
891
        if (this.hasProject(config)) {
102✔
892
            return this.getProject(config.projectKey);
1✔
893
        }
894
        const project = this.constructProject(config);
101✔
895
        await this.activateProject(project, config);
101✔
896
        return project;
100✔
897
    }
898

899
    @TrackBusyStatus
900
    private async activateProject(project: LspProject, config: ProjectConfig) {
1✔
901
        this.logger.debug('Activating project', util.getProjectLogName(project), {
106✔
902
            projectPath: config?.projectKey,
318!
903
            bsconfigPath: config.bsconfigPath
904
        });
905
        await project.activate(config);
106✔
906

907
        //send an event to indicate that this project has been activated
908
        this.emit('project-activate', { project: project });
105✔
909

910
        //register this project's list of files with the path filterer
911
        const unregister = this.pathFilterer.registerIncludeList(project.rootDir, project.filePatterns);
105✔
912
        project.disposables.push({ dispose: unregister });
105✔
913
    }
914

915
    public on(eventName: 'validate-begin', handler: (data: { project: LspProject }) => MaybePromise<void>);
916
    public on(eventName: 'validate-end', handler: (data: { project: LspProject }) => MaybePromise<void>);
917
    public on(eventName: 'critical-failure', handler: (data: { project: LspProject; message: string }) => MaybePromise<void>);
918
    public on(eventName: 'project-activate', handler: (data: { project: LspProject }) => MaybePromise<void>);
919
    public on(eventName: 'diagnostics', handler: (data: { project: LspProject; diagnostics: LspDiagnostic[] }) => MaybePromise<void>);
920
    public on(eventName: string, handler: (payload: any) => MaybePromise<void>) {
921
        this.emitter.on(eventName, handler as any);
393✔
922
        return () => {
393✔
923
            this.emitter.removeListener(eventName, handler as any);
1✔
924
        };
925
    }
926

927
    private emit(eventName: 'validate-begin', data: { project: LspProject });
928
    private emit(eventName: 'validate-end', data: { project: LspProject });
929
    private emit(eventName: 'critical-failure', data: { project: LspProject; message: string });
930
    private emit(eventName: 'project-activate', data: { project: LspProject });
931
    private emit(eventName: 'diagnostics', data: { project: LspProject; diagnostics: LspDiagnostic[] });
932
    private async emit(eventName: string, data?) {
933
        //emit these events on next tick, otherwise they will be processed immediately which could cause issues
934
        await util.sleep(0);
480✔
935
        this.emitter.emit(eventName, data);
480✔
936
    }
937
    private emitter = new EventEmitter();
109✔
938

939
    public dispose() {
940
        this.emitter.removeAllListeners();
109✔
941
        for (const project of this.projects) {
109✔
942
            project?.dispose?.();
96!
943
        }
944
    }
945
}
946

947
export interface WorkspaceConfig {
948
    /**
949
     * Absolute path to the folder where the workspace resides
950
     */
951
    workspaceFolder: string;
952
    /**
953
     * A list of glob patterns used to _exclude_ files from various bsconfig searches
954
     */
955
    excludePatterns?: string[];
956
    /**
957
     * A list of project paths that should be used to create projects in place of discovery.
958
     */
959
    projects?: BrightScriptProjectConfiguration[];
960
    /**
961
     * Language server configuration options
962
     */
963
    languageServer: {
964
        /**
965
         * Should the projects in this workspace be run in their own dedicated worker threads, or all run on the main thread
966
         */
967
        enableThreading: boolean;
968
        /**
969
         * Should the language server automatically discover projects in this workspace?
970
         */
971
        enableProjectDiscovery: boolean;
972
        /**
973
         * A list of glob patterns used to _exclude_ files from project discovery
974
         */
975
        projectDiscoveryExclude?: Record<string, boolean>;
976
        /**
977
         * The log level to use for this workspace
978
         */
979
        logLevel?: LogLevel | string;
980
        /**
981
         * Maximum depth to search for Roku projects
982
         */
983
        projectDiscoveryMaxDepth?: number;
984
    };
985
}
986

987
interface StandaloneProject extends LspProject {
988
    /**
989
     * The path to the file that this project represents
990
     */
991
    srcPath: string;
992
}
993

994
/**
995
 * An annotation used to wrap the method in a busyStatus tracking call
996
 */
997
function TrackBusyStatus(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
998
    let originalMethod = descriptor.value;
14✔
999

1000
    //wrapping the original method
1001
    descriptor.value = function value(this: ProjectManager, ...args: any[]) {
14✔
1002
        return this.busyStatusTracker.run(() => {
355✔
1003
            return originalMethod.apply(this, args);
355✔
1004
        }, originalMethod.name);
1005
    };
1006
}
1007

1008
interface DiscoveredProject {
1009
    name?: string;
1010
    bsconfigPath?: string;
1011
    dir: string;
1012
}
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