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

rokucommunity / brighterscript / #14629

18 Jul 2025 06:02PM UTC coverage: 88.928% (+0.005%) from 88.923%
#14629

push

web-flow
Fix signature help crash on malformed function declarations (#1536)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: TwitchBronBron <2544493+TwitchBronBron@users.noreply.github.com>
Co-authored-by: Bronley Plumb <bronley@gmail.com>

7715 of 9153 branches covered (84.29%)

Branch coverage included in aggregate %.

5 of 8 new or added lines in 2 files covered. (62.5%)

1 existing line in 1 file now uncovered.

9947 of 10708 relevant lines covered (92.89%)

1868.96 hits per line

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

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

55
    private pathFilterer: PathFilterer;
56

57
    private logger: Logger;
58

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

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

73
    public busyStatusTracker = new BusyStatusTracker<LspProject>();
99✔
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();
99✔
228

229
    /**
230
     * Get a promise that resolves when this manager is finished initializing
231
     */
232
    public onInitialized() {
233
        return Promise.allSettled([
143✔
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())
162✔
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();
33✔
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) {
33✔
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 });
33✔
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) {
74!
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));
74✔
281

282
        this.syncPromise = (async () => {
74✔
283
            //build a list of unique projects across all workspace folders
284
            let projectConfigs = (await Promise.all(
74✔
285
                workspaceConfigs.map(async workspaceConfig => {
286
                    const discoveredProjects = await this.discoverProjectsForWorkspace(workspaceConfig);
73✔
287
                    return discoveredProjects.map<ProjectConfig>(discoveredProject => ({
98✔
288
                        name: discoveredProject?.name,
294!
289
                        projectKey: s`${discoveredProject.bsconfigPath ?? discoveredProject.dir}`,
294✔
290
                        projectDir: s`${discoveredProject.dir}`,
291
                        bsconfigPath: discoveredProject?.bsconfigPath,
294!
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);
74✔
302

303
            //delete projects not represented in the list
304
            for (const project of this.projects) {
74✔
305
                //we can't find this existing project in our new list, so scrap it
306
                if (!projectConfigs.find(x => x.projectKey === project.projectKey)) {
11✔
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 => {
74✔
313
                return !this.hasProject(x);
98✔
314
            });
315

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

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

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

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

339
    private fileChangesQueue = new ActionQueue({
99✔
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();
1✔
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) {
1!
NEW
500
            this.logger.debug('ProjectManager getCompletions cancelled', options);
×
501
            return;
×
502
        }
503

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

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

533
    /**
534
     * Get the definition for the symbol at the given position in the file
535
     * @returns a list of locations where the symbol under the position is defined in the project
536
     */
537
    @TrackBusyStatus
538
    public async getDefinition(options: { srcPath: string; position: Position }): Promise<Location[]> {
1✔
539
        //wait for all pending syncs to finish
540
        await this.onIdle();
5✔
541

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

544
        //Ask every project for definition info, keep whichever one responds first that has a valid response
545
        let result = await util.promiseRaceMatch(
5✔
546
            this.projects.map(x => x.getDefinition(options)),
5✔
547
            //keep the first non-falsey result
548
            (result) => !!result
5✔
549
        );
550
        return result;
5✔
551
    }
552

553
    @TrackBusyStatus
554
    public async getSignatureHelp(options: { srcPath: string; position: Position }): Promise<SignatureHelp> {
1✔
555
        //wait for all pending syncs to finish
556
        await this.onIdle();
4✔
557

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

565
        if (signatures?.length > 0) {
4!
566
            const activeSignature = signatures.length > 0 ? 0 : undefined;
3!
567

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

570
            let result: SignatureHelp = {
3✔
571
                signatures: signatures.map((s) => s.signature),
3✔
572
                activeSignature: activeSignature,
573
                activeParameter: activeParameter
574
            };
575
            return result;
3✔
576
        }
577
    }
578

579
    @TrackBusyStatus
580
    public async getDocumentSymbol(options: { srcPath: string }): Promise<DocumentSymbol[]> {
1✔
581
        //wait for all pending syncs to finish
582
        await this.onIdle();
6✔
583

584
        //Ask every project for definition info, keep whichever one responds first that has a valid response
585
        let result = await util.promiseRaceMatch(
6✔
586
            this.projects.map(x => x.getDocumentSymbol(options)),
6✔
587
            //keep the first non-falsey result
588
            (result) => !!result
6✔
589
        );
590
        return result;
6✔
591
    }
592

593
    @TrackBusyStatus
594
    public async getWorkspaceSymbol(): Promise<WorkspaceSymbol[]> {
1✔
595
        //wait for all pending syncs to finish
596
        await this.onIdle();
5✔
597

598
        //Ask every project for definition info, keep whichever one responds first that has a valid response
599
        let responses = await Promise.allSettled(
5✔
600
            this.projects.map(x => x.getWorkspaceSymbol())
5✔
601
        );
602
        let results = responses
5✔
603
            //keep all symbol results
604
            .map((x) => {
605
                return x.status === 'fulfilled' ? x.value : [];
5✔
606
            })
607
            //flatten the array
608
            .flat()
609
            //throw out nulls
610
            .filter(x => !!x);
24✔
611

612
        // Remove duplicates
613
        const allSymbols = Object.values(
5✔
614
            results.reduce((map, symbol) => {
615
                const key = symbol.location.uri + symbol.name;
24✔
616
                map[key] = symbol;
24✔
617
                return map;
24✔
618
            }, {})
619
        );
620

621
        return allSymbols as SymbolInformation[];
5✔
622
    }
623

624
    @TrackBusyStatus
625
    public async getReferences(options: { srcPath: string; position: Position }): Promise<Location[]> {
1✔
626
        //wait for all pending syncs to finish
627
        await this.onIdle();
3✔
628

629
        //Ask every project for definition info, keep whichever one responds first that has a valid response
630
        let result = await util.promiseRaceMatch(
3✔
631
            this.projects.map(x => x.getReferences(options)),
3✔
632
            //keep the first non-falsey result
633
            (result) => !!result
3✔
634
        );
635
        return result ?? [];
3!
636
    }
637

638
    @TrackBusyStatus
639
    public async getCodeActions(options: { srcPath: string; range: Range }) {
1✔
640
        //wait for all pending syncs to finish
641
        await this.onIdle();
×
642

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

652
    /**
653
     * Scan a given workspace for all `bsconfig.json` files. If at least one is found, then only folders who have bsconfig.json are returned.
654
     * If none are found, then the workspaceFolder itself is treated as a project
655
     */
656
    private async discoverProjectsForWorkspace(workspaceConfig: WorkspaceConfig): Promise<DiscoveredProject[]> {
657
        //config may provide a list of project paths. If we have these, no other discovery is permitted
658
        if (Array.isArray(workspaceConfig.projects) && workspaceConfig.projects.length > 0) {
73✔
659
            this.logger.debug(`Using project paths from workspace config`, workspaceConfig.projects);
3✔
660
            const projectConfigs = workspaceConfig.projects.reduce<DiscoveredProject[]>((acc, project) => {
3✔
661
                //skip this project if it's disabled or we don't have a path
662
                if (project.disabled || !project.path) {
5!
663
                    return acc;
×
664
                }
665
                //ensure the project path is absolute
666
                if (!path.isAbsolute(project.path)) {
5!
667
                    project.path = path.resolve(workspaceConfig.workspaceFolder, project.path);
×
668
                }
669

670
                //skip this project if the path does't exist
671
                if (!fsExtra.existsSync(project.path)) {
5!
672
                    return acc;
×
673
                }
674

675
                //if the project is a directory
676
                if (fsExtra.statSync(project.path).isDirectory()) {
5✔
677
                    acc.push({
2✔
678
                        name: project.name,
679
                        bsconfigPath: undefined,
680
                        dir: project.path
681
                    });
682
                    //it's a path to a file (hopefully bsconfig.json)
683
                } else {
684
                    acc.push({
3✔
685
                        name: project.name,
686
                        dir: path.dirname(project.path),
687
                        bsconfigPath: project.path
688
                    });
689
                }
690
                return acc;
5✔
691
            }, []);
692

693
            //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
694
            //disabled all their projects on purpose
695
            if (projectConfigs.length === 0) {
3!
696
                this.logger.warn(`No valid project paths found in workspace config`, JSON.stringify(workspaceConfig.projects, null, 4));
×
697
            }
698
            return projectConfigs;
3✔
699
        }
700

701
        //automatic discovery disabled?
702
        if (!workspaceConfig.languageServer.enableProjectDiscovery) {
70✔
703
            return [{
1✔
704
                dir: workspaceConfig.workspaceFolder
705
            }];
706
        }
707

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

711
        let files = await fastGlob(['**/bsconfig.json', ...excludePatterns], {
69✔
712
            cwd: workspaceConfig.workspaceFolder,
713
            followSymbolicLinks: false,
714
            absolute: true,
715
            onlyFiles: true,
716
            deep: workspaceConfig.languageServer.projectDiscoveryMaxDepth ?? 15
207✔
717
        });
718

719
        //filter the files to only include those that are allowed by the path filterer
720
        files = this.pathFilterer.filter(files);
69✔
721

722
        //if we found at least one bsconfig.json, then ALL projects must have a bsconfig.json.
723
        if (files.length > 0) {
69✔
724
            return files.map(file => ({
47✔
725
                dir: s`${path.dirname(file)}`,
726
                bsconfigPath: s`${file}`
727
            }));
728
        }
729

730
        //look for roku project folders
731
        let rokuLikeDirs = (await Promise.all(
40✔
732
            //find all folders containing a `manifest` file
733
            (await fastGlob(['**/manifest', ...excludePatterns], {
734
                cwd: workspaceConfig.workspaceFolder,
735
                followSymbolicLinks: false,
736
                absolute: true,
737
                onlyFiles: true,
738
                deep: workspaceConfig.languageServer.projectDiscoveryMaxDepth ?? 15
120✔
739
            })).map(async manifestEntry => {
740
                const manifestDir = path.dirname(manifestEntry);
11✔
741
                //TODO validate that manifest is a Roku manifest
742
                const files = await rokuDeploy.getFilePaths([
11✔
743
                    'source/**/*.{brs,bs}',
744
                    ...excludePatterns
745
                ], manifestDir);
746
                if (files.length > 0) {
11✔
747
                    return s`${manifestDir}`;
9✔
748
                }
749
            })
750
            //throw out nulls
751
        )).filter(x => !!x);
11✔
752

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

756
        if (rokuLikeDirs.length > 0) {
40✔
757
            return rokuLikeDirs.map(file => ({
9✔
758
                dir: file
759
            }));
760
        }
761

762
        //treat the workspace folder as a brightscript project itself
763
        return [{
36✔
764
            dir: workspaceConfig.workspaceFolder
765
        }];
766
    }
767

768
    /**
769
     * Returns true if we have this project, or false if we don't
770
     * @returns true if the project exists, or false if it doesn't
771
     */
772
    private hasProject(config: Partial<ProjectConfig>) {
773
        return !!this.getProject(config);
298✔
774
    }
775

776
    /**
777
     * Get a project with the specified path
778
     * @param param path to the project or an obj that has `projectPath` prop
779
     * @returns a project, or undefined if no project was found
780
     */
781
    private getProject(param: string | Partial<ProjectConfig>) {
782
        const projectKey = util.standardizePath(
300✔
783
            (typeof param === 'string') ? param : (param?.projectKey ?? param?.bsconfigPath ?? param?.projectDir)
2,991!
784
        );
785
        if (!projectKey) {
300!
786
            return;
×
787
        }
788
        return this.projects.find(x => x.projectKey === projectKey);
300✔
789
    }
790

791
    /**
792
     * Remove a project from the language server
793
     */
794
    private removeProject(project: LspProject) {
795
        const idx = this.projects.findIndex(x => x.projectKey === project?.projectKey);
14✔
796
        if (idx > -1) {
14✔
797
            this.logger.log('Removing project', { projectKey: project.projectKey, projectNumber: project.projectNumber });
11✔
798
            this.projects.splice(idx, 1);
11✔
799
        }
800
        //anytime we remove a project, we should emit an event that clears all of its diagnostics
801
        this.emit('diagnostics', { project: project, diagnostics: [] });
14✔
802
        project?.dispose();
14✔
803
        this.busyStatusTracker.endAllRunsForScope(project);
14✔
804
    }
805

806
    /**
807
     * A unique project counter to help distinguish log entries in lsp mode
808
     */
809
    private static projectNumberSequence = 0;
1✔
810

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

813
    /**
814
     * Get a projectNumber for a given config. Try to reuse project numbers when we've seen this project before
815
     *  - If the config already has one, use that.
816
     *  - If we've already seen this config before, use the same project number as before
817
     */
818
    private getProjectNumber(config: ProjectConfig) {
819
        if (config.projectNumber !== undefined) {
102✔
820
            return config.projectNumber;
9✔
821
        }
822
        const key = s`${config.projectKey}` + '-' + s`${config.workspaceFolder}` + '-' + s`${config.bsconfigPath}`;
93✔
823
        return ProjectManager.projectNumberCache.getOrAdd(key, () => {
93✔
824
            return ProjectManager.projectNumberSequence++;
26✔
825
        });
826
    }
827

828
    /**
829
     * Constructs a project for the given config. Just makes the project, doesn't activate it
830
     * @returns a new project, or the existing project if one already exists with this config info
831
     */
832
    private constructProject(config: ProjectConfig): LspProject {
833
        //skip this project if we already have it
834
        if (this.hasProject(config)) {
102!
835
            return this.getProject(config);
×
836
        }
837

838
        config.projectNumber = this.getProjectNumber(config);
102✔
839

840
        let project: LspProject = config.enableThreading
102✔
841
            ? new WorkerThreadProject({
102✔
842
                logger: this.logger.createLogger()
843
            })
844
            : new Project({
845
                logger: this.logger.createLogger()
846
            });
847

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

850
        this.projects.push(project);
102✔
851

852
        //pipe all project-specific events through our emitter, and include the project reference
853
        project.on('all', (eventName, data) => {
102✔
854
            this.emit(eventName as any, {
344✔
855
                ...data,
856
                project: project
857
            } as any);
858
        });
859
        return project;
102✔
860
    }
861

862
    /**
863
     * Constructs a project for the given config
864
     * @returns a new project, or the existing project if one already exists with this config info
865
     */
866
    @TrackBusyStatus
867
    private async createAndActivateProject(config: ProjectConfig): Promise<LspProject> {
1✔
868
        //skip this project if we already have it
869
        if (this.hasProject(config)) {
98✔
870
            return this.getProject(config.projectKey);
1✔
871
        }
872
        const project = this.constructProject(config);
97✔
873
        await this.activateProject(project, config);
97✔
874
        return project;
96✔
875
    }
876

877
    @TrackBusyStatus
878
    private async activateProject(project: LspProject, config: ProjectConfig) {
1✔
879
        this.logger.debug('Activating project', util.getProjectLogName(project), {
102✔
880
            projectPath: config?.projectKey,
306!
881
            bsconfigPath: config.bsconfigPath
882
        });
883
        await project.activate(config);
102✔
884

885
        //send an event to indicate that this project has been activated
886
        this.emit('project-activate', { project: project });
101✔
887

888
        //register this project's list of files with the path filterer
889
        const unregister = this.pathFilterer.registerIncludeList(project.rootDir, project.filePatterns);
101✔
890
        project.disposables.push({ dispose: unregister });
101✔
891
    }
892

893
    public on(eventName: 'validate-begin', handler: (data: { project: LspProject }) => MaybePromise<void>);
894
    public on(eventName: 'validate-end', handler: (data: { project: LspProject }) => MaybePromise<void>);
895
    public on(eventName: 'critical-failure', handler: (data: { project: LspProject; message: string }) => MaybePromise<void>);
896
    public on(eventName: 'project-activate', handler: (data: { project: LspProject }) => MaybePromise<void>);
897
    public on(eventName: 'diagnostics', handler: (data: { project: LspProject; diagnostics: LspDiagnostic[] }) => MaybePromise<void>);
898
    public on(eventName: string, handler: (payload: any) => MaybePromise<void>) {
899
        this.emitter.on(eventName, handler as any);
354✔
900
        return () => {
354✔
901
            this.emitter.removeListener(eventName, handler as any);
1✔
902
        };
903
    }
904

905
    private emit(eventName: 'validate-begin', data: { project: LspProject });
906
    private emit(eventName: 'validate-end', data: { project: LspProject });
907
    private emit(eventName: 'critical-failure', data: { project: LspProject; message: string });
908
    private emit(eventName: 'project-activate', data: { project: LspProject });
909
    private emit(eventName: 'diagnostics', data: { project: LspProject; diagnostics: LspDiagnostic[] });
910
    private async emit(eventName: string, data?) {
911
        //emit these events on next tick, otherwise they will be processed immediately which could cause issues
912
        await util.sleep(0);
464✔
913
        this.emitter.emit(eventName, data);
464✔
914
    }
915
    private emitter = new EventEmitter();
99✔
916

917
    public dispose() {
918
        this.emitter.removeAllListeners();
99✔
919
        for (const project of this.projects) {
99✔
920
            project?.dispose?.();
92!
921
        }
922
    }
923
}
924

925
export interface WorkspaceConfig {
926
    /**
927
     * Absolute path to the folder where the workspace resides
928
     */
929
    workspaceFolder: string;
930
    /**
931
     * A list of glob patterns used to _exclude_ files from various bsconfig searches
932
     */
933
    excludePatterns?: string[];
934
    /**
935
     * A list of project paths that should be used to create projects in place of discovery.
936
     */
937
    projects?: BrightScriptProjectConfiguration[];
938
    /**
939
     * Language server configuration options
940
     */
941
    languageServer: {
942
        /**
943
         * Should the projects in this workspace be run in their own dedicated worker threads, or all run on the main thread
944
         */
945
        enableThreading: boolean;
946
        /**
947
         * Should the language server automatically discover projects in this workspace?
948
         */
949
        enableProjectDiscovery: boolean;
950
        /**
951
         * The log level to use for this workspace
952
         */
953
        logLevel?: LogLevel | string;
954
        /**
955
         * Maximum depth to search for Roku projects
956
         */
957
        projectDiscoveryMaxDepth?: number;
958
    };
959
}
960

961
interface StandaloneProject extends LspProject {
962
    /**
963
     * The path to the file that this project represents
964
     */
965
    srcPath: string;
966
}
967

968
/**
969
 * An annotation used to wrap the method in a busyStatus tracking call
970
 */
971
function TrackBusyStatus(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
972
    let originalMethod = descriptor.value;
14✔
973

974
    //wrapping the original method
975
    descriptor.value = function value(this: ProjectManager, ...args: any[]) {
14✔
976
        return this.busyStatusTracker.run(() => {
342✔
977
            return originalMethod.apply(this, args);
342✔
978
        }, originalMethod.name);
979
    };
980
}
981

982
interface DiscoveredProject {
983
    name?: string;
984
    bsconfigPath?: string;
985
    dir: string;
986
}
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