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

rokucommunity / brighterscript / #13346

25 Nov 2024 08:45PM UTC coverage: 89.053%. Remained the same
#13346

push

web-flow
Merge 0e0c5f219 into 9b7e0c255

7359 of 8712 branches covered (84.47%)

Branch coverage included in aggregate %.

20 of 20 new or added lines in 4 files covered. (100.0%)

42 existing lines in 3 files now uncovered.

9724 of 10471 relevant lines covered (92.87%)

1825.22 hits per line

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

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

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

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

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

54
    private pathFilterer: PathFilterer;
55

56
    private logger: Logger;
57

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

525
    /**
526
     * Get the definition for the symbol at the given position in the file
527
     * @returns a list of locations where the symbol under the position is defined in the project
528
     */
529
    @TrackBusyStatus
530
    public async getDefinition(options: { srcPath: string; position: Position }): Promise<Location[]> {
1✔
531
        //wait for all pending syncs to finish
532
        await this.onIdle();
5✔
533

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

536
        //Ask every project for definition info, keep whichever one responds first that has a valid response
537
        let result = await util.promiseRaceMatch(
5✔
538
            this.projects.map(x => x.getDefinition(options)),
5✔
539
            //keep the first non-falsey result
540
            (result) => !!result
5✔
541
        );
542
        return result;
5✔
543
    }
544

545
    @TrackBusyStatus
546
    public async getSignatureHelp(options: { srcPath: string; position: Position }): Promise<SignatureHelp> {
1✔
547
        //wait for all pending syncs to finish
548
        await this.onIdle();
4✔
549

550
        //Ask every project for definition info, keep whichever one responds first that has a valid response
551
        let signatures = await util.promiseRaceMatch(
4✔
552
            this.projects.map(x => x.getSignatureHelp(options)),
4✔
553
            //keep the first non-falsey result
554
            (result) => !!result
4✔
555
        );
556

557
        if (signatures?.length > 0) {
4!
558
            const activeSignature = signatures.length > 0 ? 0 : undefined;
3!
559

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

562
            let result: SignatureHelp = {
3✔
563
                signatures: signatures.map((s) => s.signature),
3✔
564
                activeSignature: activeSignature,
565
                activeParameter: activeParameter
566
            };
567
            return result;
3✔
568
        }
569
    }
570

571
    @TrackBusyStatus
572
    public async getDocumentSymbol(options: { srcPath: string }): Promise<DocumentSymbol[]> {
1✔
573
        //wait for all pending syncs to finish
574
        await this.onIdle();
6✔
575

576
        //Ask every project for definition info, keep whichever one responds first that has a valid response
577
        let result = await util.promiseRaceMatch(
6✔
578
            this.projects.map(x => x.getDocumentSymbol(options)),
6✔
579
            //keep the first non-falsey result
580
            (result) => !!result
6✔
581
        );
582
        return result;
6✔
583
    }
584

585
    @TrackBusyStatus
586
    public async getWorkspaceSymbol(): Promise<WorkspaceSymbol[]> {
1✔
587
        //wait for all pending syncs to finish
588
        await this.onIdle();
5✔
589

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

604
        // Remove duplicates
605
        const allSymbols = Object.values(
5✔
606
            results.reduce((map, symbol) => {
607
                const key = symbol.location.uri + symbol.name;
24✔
608
                map[key] = symbol;
24✔
609
                return map;
24✔
610
            }, {})
611
        );
612

613
        return allSymbols as SymbolInformation[];
5✔
614
    }
615

616
    @TrackBusyStatus
617
    public async getReferences(options: { srcPath: string; position: Position }): Promise<Location[]> {
1✔
618
        //wait for all pending syncs to finish
619
        await this.onIdle();
3✔
620

621
        //Ask every project for definition info, keep whichever one responds first that has a valid response
622
        let result = await util.promiseRaceMatch(
3✔
623
            this.projects.map(x => x.getReferences(options)),
3✔
624
            //keep the first non-falsey result
625
            (result) => !!result
3✔
626
        );
627
        return result ?? [];
3!
628
    }
629

630
    @TrackBusyStatus
631
    public async getCodeActions(options: { srcPath: string; range: Range }) {
1✔
632
        //wait for all pending syncs to finish
UNCOV
633
        await this.onIdle();
×
634

635
        //Ask every project for definition info, keep whichever one responds first that has a valid response
UNCOV
636
        let result = await util.promiseRaceMatch(
×
UNCOV
637
            this.projects.map(x => x.getCodeActions(options)),
×
638
            //keep the first non-falsey result
UNCOV
639
            (result) => !!result
×
640
        );
UNCOV
641
        return result;
×
642
    }
643

644
    /**
645
     * Scan a given workspace for all `bsconfig.json` files. If at least one is found, then only folders who have bsconfig.json are returned.
646
     * If none are found, then the workspaceFolder itself is treated as a project
647
     */
648
    private async getProjectPaths(workspaceConfig: WorkspaceConfig) {
649
        //get the list of exclude patterns, and negate them (so they actually work like excludes)
650
        const excludePatterns = (workspaceConfig.excludePatterns ?? []).map(x => s`!${x}`);
62✔
651
        let files = await rokuDeploy.getFilePaths([
62✔
652
            '**/bsconfig.json',
653
            //exclude all files found in `files.exclude`
654
            ...excludePatterns
655
        ], workspaceConfig.workspaceFolder);
656

657
        //filter the files to only include those that are allowed by the path filterer
658
        files = this.pathFilterer.filter(files, x => x.src);
62✔
659

660
        //if we found at least one bsconfig.json, then ALL projects must have a bsconfig.json.
661
        if (files.length > 0) {
62✔
662
            return files.map(file => s`${path.dirname(file.src)}`);
32✔
663
        }
664

665
        //look for roku project folders
666
        let rokuLikeDirs = (await Promise.all(
38✔
667
            //find all folders containing a `manifest` file
668
            (await rokuDeploy.getFilePaths([
669
                '**/manifest',
670
                ...excludePatterns
671

672
                //is there at least one .bs|.brs file under the `/source` folder?
673
            ], workspaceConfig.workspaceFolder)).map(async manifestEntry => {
674
                const manifestDir = path.dirname(manifestEntry.src);
5✔
675
                const files = await rokuDeploy.getFilePaths([
5✔
676
                    'source/**/*.{brs,bs}',
677
                    ...excludePatterns
678
                ], manifestDir);
679
                if (files.length > 0) {
5✔
680
                    return manifestDir;
3✔
681
                }
682
            })
683
            //throw out nulls
684
        )).filter(x => !!x);
5✔
685

686
        //throw out any directories that are not allowed by the path filterer
687
        rokuLikeDirs = this.pathFilterer.filter(rokuLikeDirs, srcPath => srcPath);
38✔
688

689
        if (rokuLikeDirs.length > 0) {
38✔
690
            return rokuLikeDirs;
2✔
691
        }
692

693
        //treat the workspace folder as a brightscript project itself
694
        return [workspaceConfig.workspaceFolder];
36✔
695
    }
696

697
    /**
698
     * Returns true if we have this project, or false if we don't
699
     * @param projectPath path to the project
700
     * @returns true if the project exists, or false if it doesn't
701
     */
702
    private hasProject(projectPath: string) {
703
        return !!this.getProject(projectPath);
217✔
704
    }
705

706
    /**
707
     * Get a project with the specified path
708
     * @param param path to the project or an obj that has `projectPath` prop
709
     * @returns a project, or undefined if no project was found
710
     */
711
    private getProject(param: string | { projectPath: string }) {
712
        const projectPath = util.standardizePath(
219✔
713
            (typeof param === 'string') ? param : param.projectPath
219✔
714
        );
715
        return this.projects.find(x => x.projectPath === projectPath);
219✔
716
    }
717

718
    /**
719
     * Remove a project from the language server
720
     */
721
    private removeProject(project: LspProject) {
722
        const idx = this.projects.findIndex(x => x.projectPath === project?.projectPath);
14✔
723
        if (idx > -1) {
14✔
724
            this.logger.log('Removing project', { projectPath: project.projectPath, projectNumber: project.projectNumber });
11✔
725
            this.projects.splice(idx, 1);
11✔
726
        }
727
        //anytime we remove a project, we should emit an event that clears all of its diagnostics
728
        this.emit('diagnostics', { project: project, diagnostics: [] });
14✔
729
        project?.dispose();
14✔
730
        this.busyStatusTracker.endAllRunsForScope(project);
14✔
731
    }
732

733
    /**
734
     * A unique project counter to help distinguish log entries in lsp mode
735
     */
736
    private static projectNumberSequence = 0;
1✔
737

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

740
    /**
741
     * Get a projectNumber for a given config. Try to reuse project numbers when we've seen this project before
742
     *  - If the config already has one, use that.
743
     *  - If we've already seen this config before, use the same project number as before
744
     */
745
    private getProjectNumber(config: ProjectConfig) {
746
        if (config.projectNumber !== undefined) {
75✔
747
            return config.projectNumber;
9✔
748
        }
749
        return ProjectManager.projectNumberCache.getOrAdd(`${s(config.projectPath)}-${s(config.workspaceFolder)}-${config.bsconfigPath}`, () => {
66✔
750
            return ProjectManager.projectNumberSequence++;
11✔
751
        });
752
    }
753

754
    /**
755
     * Constructs a project for the given config. Just makes the project, doesn't activate it
756
     * @returns a new project, or the existing project if one already exists with this config info
757
     */
758
    private constructProject(config: ProjectConfig): LspProject {
759
        //skip this project if we already have it
760
        if (this.hasProject(config.projectPath)) {
75!
UNCOV
761
            return this.getProject(config.projectPath);
×
762
        }
763

764
        config.projectNumber = this.getProjectNumber(config);
75✔
765
        const projectIdentifier = `prj${config.projectNumber}`;
75✔
766

767
        let project: LspProject = config.enableThreading
75✔
768
            ? new WorkerThreadProject({
75✔
769
                logger: this.logger.createLogger(),
770
                projectIdentifier: projectIdentifier
771
            })
772
            : new Project({
773
                logger: this.logger.createLogger(),
774
                projectIdentifier: projectIdentifier
775
            });
776

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

779
        this.projects.push(project);
75✔
780

781
        //pipe all project-specific events through our emitter, and include the project reference
782
        project.on('all', (eventName, data) => {
75✔
783
            this.emit(eventName as any, {
266✔
784
                ...data,
785
                project: project
786
            } as any);
787
        });
788
        return project;
75✔
789
    }
790

791
    /**
792
     * Constructs a project for the given config
793
     * @returns a new project, or the existing project if one already exists with this config info
794
     */
795
    @TrackBusyStatus
796
    private async createAndActivateProject(config: ProjectConfig): Promise<LspProject> {
1✔
797
        //skip this project if we already have it
798
        if (this.hasProject(config.projectPath)) {
71✔
799
            return this.getProject(config.projectPath);
1✔
800
        }
801
        const project = this.constructProject(config);
70✔
802
        await this.activateProject(project, config);
70✔
803
        return project;
69✔
804
    }
805

806
    @TrackBusyStatus
807
    private async activateProject(project: LspProject, config: ProjectConfig) {
1✔
808
        this.logger.debug('Activating project', project.projectIdentifier, {
75✔
809
            projectPath: config?.projectPath,
225!
810
            bsconfigPath: config.bsconfigPath
811
        });
812
        await project.activate(config);
75✔
813

814
        //send an event to indicate that this project has been activated
815
        this.emit('project-activate', { project: project });
74✔
816

817
        //register this project's list of files with the path filterer
818
        const unregister = this.pathFilterer.registerIncludeList(project.rootDir, project.filePatterns);
74✔
819
        project.disposables.push({ dispose: unregister });
74✔
820
    }
821

822
    public on(eventName: 'validate-begin', handler: (data: { project: LspProject }) => MaybePromise<void>);
823
    public on(eventName: 'validate-end', handler: (data: { project: LspProject }) => MaybePromise<void>);
824
    public on(eventName: 'critical-failure', handler: (data: { project: LspProject; message: string }) => MaybePromise<void>);
825
    public on(eventName: 'project-activate', handler: (data: { project: LspProject }) => MaybePromise<void>);
826
    public on(eventName: 'diagnostics', handler: (data: { project: LspProject; diagnostics: LspDiagnostic[] }) => MaybePromise<void>);
827
    public on(eventName: string, handler: (payload: any) => MaybePromise<void>) {
828
        this.emitter.on(eventName, handler as any);
317✔
829
        return () => {
317✔
830
            this.emitter.removeListener(eventName, handler as any);
1✔
831
        };
832
    }
833

834
    private emit(eventName: 'validate-begin', data: { project: LspProject });
835
    private emit(eventName: 'validate-end', data: { project: LspProject });
836
    private emit(eventName: 'critical-failure', data: { project: LspProject; message: string });
837
    private emit(eventName: 'project-activate', data: { project: LspProject });
838
    private emit(eventName: 'diagnostics', data: { project: LspProject; diagnostics: LspDiagnostic[] });
839
    private async emit(eventName: string, data?) {
840
        //emit these events on next tick, otherwise they will be processed immediately which could cause issues
841
        await util.sleep(0);
359✔
842
        this.emitter.emit(eventName, data);
359✔
843
    }
844
    private emitter = new EventEmitter();
87✔
845

846
    public dispose() {
847
        this.emitter.removeAllListeners();
87✔
848
        for (const project of this.projects) {
87✔
849
            project?.dispose?.();
65!
850
        }
851
    }
852
}
853

854
export interface WorkspaceConfig {
855
    /**
856
     * Absolute path to the folder where the workspace resides
857
     */
858
    workspaceFolder: string;
859
    /**
860
     * A list of glob patterns used to _exclude_ files from various bsconfig searches
861
     */
862
    excludePatterns?: string[];
863
    /**
864
     * Path to a bsconfig that should be used instead of the auto-discovery algorithm. If this is present, no bsconfig discovery should be used. and an error should be emitted if this file is missing
865
     */
866
    bsconfigPath?: string;
867
    /**
868
     * Should the projects in this workspace be run in their own dedicated worker threads, or all run on the main thread
869
     */
870
    enableThreading?: boolean;
871
}
872

873
interface StandaloneProject extends LspProject {
874
    /**
875
     * The path to the file that this project represents
876
     */
877
    srcPath: string;
878
}
879

880
/**
881
 * An annotation used to wrap the method in a busyStatus tracking call
882
 */
883
function TrackBusyStatus(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
884
    let originalMethod = descriptor.value;
14✔
885

886
    //wrapping the original method
887
    descriptor.value = function value(this: ProjectManager, ...args: any[]) {
14✔
888
        return this.busyStatusTracker.run(() => {
277✔
889
            return originalMethod.apply(this, args);
277✔
890
        }, originalMethod.name);
891
    };
892
}
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