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

rokucommunity / brighterscript / #13238

28 Oct 2024 07:44PM UTC coverage: 89.066% (+0.9%) from 88.214%
#13238

push

web-flow
Merge 2aeae9b6e into 7cfaaa047

7233 of 8558 branches covered (84.52%)

Branch coverage included in aggregate %.

1076 of 1197 new or added lines in 28 files covered. (89.89%)

24 existing lines in 5 files now uncovered.

9621 of 10365 relevant lines covered (92.82%)

1782.28 hits per line

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

88.22
/src/lsp/ProjectManager.ts
1
import { standardizePath as s, util } from '../util';
1✔
2
import { rokuDeploy } from 'roku-deploy';
1✔
3
import * as path from 'path';
1✔
4
import * as EventEmitter from 'eventemitter3';
1✔
5
import type { LspDiagnostic, LspProject, ProjectConfig } from './LspProject';
6
import { Project } from './Project';
1✔
7
import { WorkerThreadProject } from './worker/WorkerThreadProject';
1✔
8
import { FileChangeType } from 'vscode-languageserver-protocol';
1✔
9
import type { Hover, Position, Range, Location, SignatureHelp, DocumentSymbol, SymbolInformation, WorkspaceSymbol, CompletionList, CancellationToken } 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

22
/**
23
 * Manages all brighterscript projects for the language server
24
 */
25
export class ProjectManager {
1✔
26
    constructor(options?: {
27
        pathFilterer: PathFilterer;
28
        logger?: Logger;
29
    }) {
30
        this.logger = options?.logger ?? createLogger();
84!
31
        this.pathFilterer = options?.pathFilterer ?? new PathFilterer({ logger: options?.logger });
84!
32
        this.documentManager = new DocumentManager({
84✔
33
            delay: ProjectManager.documentManagerDelay,
34
            flushHandler: (event) => {
35
                return this.flushDocumentChanges(event).catch(e => console.error(e));
36✔
36
            }
37
        });
38

39
        this.on('validate-begin', (event) => {
84✔
40
            this.busyStatusTracker.beginScopedRun(event.project, `validate-project`);
83✔
41
        });
42
        this.on('validate-end', (event) => {
84✔
43
            void this.busyStatusTracker.endScopedRun(event.project, `validate-project`);
80✔
44
        });
45
    }
46

47
    private pathFilterer: PathFilterer;
48

49
    private logger: Logger;
50

51
    /**
52
     * Collection of all projects
53
     */
54
    public projects: LspProject[] = [];
84✔
55

56
    /**
57
     * Collection of standalone projects. These are projects that are not part of a workspace, but are instead single files.
58
     * All of these are also present in the `projects` collection.
59
     */
60
    private standaloneProjects: StandaloneProject[] = [];
84✔
61

62
    private documentManager: DocumentManager;
63
    public static documentManagerDelay = 150;
1✔
64

65
    public busyStatusTracker = new BusyStatusTracker<LspProject>();
84✔
66

67
    /**
68
     * Apply all of the queued document changes. This should only be called as a result of the documentManager flushing changes, and never called manually
69
     * @param event the document changes that have occurred since the last time we applied
70
     */
71
    @TrackBusyStatus
72
    private async flushDocumentChanges(event: FlushEvent) {
1✔
73

74
        this.logger.info(`flushDocumentChanges`, event?.actions?.map(x => ({
70!
75
            type: x.type,
76
            srcPath: x.srcPath,
77
            allowStandaloneProject: x.allowStandaloneProject
78
        })));
79

80
        //ensure that we're fully initialized before proceeding
81
        await this.onInitialized();
37✔
82

83
        const actions = [...event.actions] as DocumentActionWithStatus[];
37✔
84

85
        let idSequence = 0;
37✔
86
        //add an ID to every action (so we can track which actions were handled by which projects)
87
        for (const action of actions) {
37✔
88
            action.id = idSequence++;
70✔
89
        }
90

91
        //apply all of the document actions to each project in parallel
92
        const responses = await Promise.all(this.projects.map(async (project) => {
37✔
93
            //wait for this project to finish activating
94
            await project.whenActivated();
40✔
95

96
            const filterer = new PathCollection({
40✔
97
                rootDir: project.rootDir,
98
                globs: project.filePatterns
99
            });
100
            // only include files that are applicable to this specific project (still allow deletes to flow through since they're cheap)
101
            const projectActions = actions.filter(action => {
40✔
102
                return action.type === 'delete' || filterer.isMatch(action.srcPath);
76✔
103
            });
104
            if (projectActions.length > 0) {
40✔
105
                const responseActions = await project.applyFileChanges(projectActions);
34✔
106
                return responseActions.map(x => ({
66✔
107
                    project: project,
108
                    action: x
109
                }));
110
            }
111
        }));
112

113
        //create standalone projects for any files not handled by any project
114
        const flatResponses = responses.flat();
37✔
115
        for (const action of actions) {
37✔
116
            //skip this action if it doesn't support standalone projects
117
            if (!action.allowStandaloneProject || action.type !== 'set') {
70✔
118
                continue;
26✔
119
            }
120

121
            //a list of responses that handled this action
122
            const handledResponses = flatResponses.filter(x => x?.action?.id === action.id && x?.action?.status === 'accepted');
131!
123

124
            //remove any standalone project created for this file since it was handled by a normal project
125
            if (handledResponses.some(x => x.project.isStandaloneProject === false)) {
44✔
126
                this.removeStandaloneProject(action.srcPath);
40✔
127

128
                // create a standalone project if this action was handled by zero normal projects.
129
                //(save to call even if there's already a standalone project, won't create dupes)
130
            } else {
131
                //TODO only create standalone projects for files we understand (brightscript, brighterscript, scenegraph xml, etc)
132
                await this.createStandaloneProject(action.srcPath);
4✔
133
            }
134
            this.logger.info('flushDocumentChanges complete', actions.map(x => ({
130✔
135
                type: x.type,
136
                srcPath: x.srcPath,
137
                allowStandaloneProject: x.allowStandaloneProject
138
            })));
139
        }
140
    }
141

142
    /**
143
     * Get a standalone project for a given file path
144
     */
145
    private getStandaloneProject(srcPath: string) {
146
        srcPath = util.standardizePath(srcPath);
4✔
147
        return this.standaloneProjects.find(x => x.srcPath === srcPath);
4✔
148
    }
149

150
    /**
151
     * Create a project that validates a single file. This is useful for getting language support for files that don't belong to a project
152
     */
153
    private async createStandaloneProject(srcPath: string) {
154
        srcPath = util.standardizePath(srcPath);
4✔
155

156
        //if we already have a standalone project with this path, do nothing because it already exists
157
        if (this.getStandaloneProject(srcPath)) {
4✔
158
            this.logger.log('createStandaloneProject skipping because we already have one for this path');
1✔
159
            return;
1✔
160
        }
161

162
        this.logger.log(`Creating standalone project for '${srcPath}'`);
3✔
163

164
        const projectNumber = ProjectManager.projectNumberSequence++;
3✔
165
        const rootDir = path.join(__dirname, `standalone-project-${projectNumber}`);
3✔
166
        const projectOptions = {
3✔
167
            //these folders don't matter for standalone projects
168
            workspaceFolder: rootDir,
169
            projectPath: rootDir,
170
            enableThreading: false,
171
            projectNumber: projectNumber,
172
            files: [{
173
                src: srcPath,
174
                dest: 'source/standalone.brs'
175
            }]
176
        };
177

178
        const project = this.constructProject(projectOptions) as StandaloneProject;
3✔
179
        project.srcPath = srcPath;
3✔
180
        project.isStandaloneProject = true;
3✔
181

182
        this.standaloneProjects.push(project);
3✔
183
        await this.activateProject(project, projectOptions);
3✔
184
    }
185

186
    private removeStandaloneProject(srcPath: string) {
187
        srcPath = util.standardizePath(srcPath);
41✔
188
        //remove all standalone projects that have this srcPath
189
        for (let i = this.standaloneProjects.length - 1; i >= 0; i--) {
41✔
190
            const project = this.standaloneProjects[i];
2✔
191
            if (project.srcPath === srcPath) {
2!
192
                this.removeProject(project);
2✔
193
                this.standaloneProjects.splice(i, 1);
2✔
194
            }
195
        }
196
    }
197

198
    /**
199
     * A promise that's set when a sync starts, and resolved when the sync is complete
200
     */
201
    private syncPromise: Promise<void> | undefined;
202
    private firstSync = new Deferred();
84✔
203

204
    /**
205
     * Get a promise that resolves when this manager is finished initializing
206
     */
207
    public onInitialized() {
208
        return Promise.allSettled([
129✔
209
            //wait for the first sync to finish
210
            this.firstSync.promise,
211
            //make sure we're not in the middle of a sync
212
            this.syncPromise,
213
            //make sure all projects are activated
214
            ...this.projects.map(x => x.whenActivated())
136✔
215
        ]);
216
    }
217
    /**
218
     * Get a promise that resolves when the project manager is idle (no pending work)
219
     */
220
    public async onIdle() {
221
        await this.onInitialized();
32✔
222

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

228
            await Promise.allSettled([
11✔
229
                //make sure all pending file changes have been flushed
230
                this.documentManager.onIdle(),
231
                //wait for the file changes queue to be idle
232
                this.fileChangesQueue.onIdle()
233
            ]);
234
        }
235

236
        this.logger.info('onIdle debug', { documentManagerIdle: this.documentManager.isIdle, fileChangesQueueIdle: this.fileChangesQueue.isIdle });
32✔
237
    }
238

239
    /**
240
     * Given a list of all desired projects, create any missing projects and destroy and projects that are no longer available
241
     * Treat workspaces that don't have a bsconfig.json as a project.
242
     * Handle situations where bsconfig.json files were added or removed (to elevate/lower workspaceFolder projects accordingly)
243
     * Leave existing projects alone if they are not affected by these changes
244
     * @param workspaceConfigs an array of workspaces
245
     */
246
    @TrackBusyStatus
247
    public async syncProjects(workspaceConfigs: WorkspaceConfig[], forceReload = false) {
1✔
248
        //if we're force reloading, destroy all projects and start fresh
249
        if (forceReload) {
60!
NEW
250
            this.logger.log('syncProjects: forceReload is true so removing all existing projects');
×
NEW
251
            for (const project of this.projects) {
×
NEW
252
                this.removeProject(project);
×
253
            }
254
        }
255
        this.logger.log('syncProjects', workspaceConfigs.map(x => x.workspaceFolder));
60✔
256

257
        this.syncPromise = (async () => {
60✔
258
            //build a list of unique projects across all workspace folders
259
            let projectConfigs = (await Promise.all(
60✔
260
                workspaceConfigs.map(async workspaceConfig => {
261
                    const projectPaths = await this.getProjectPaths(workspaceConfig);
59✔
262
                    return projectPaths.map(projectPath => ({
68✔
263
                        projectPath: s`${projectPath}`,
264
                        workspaceFolder: s`${workspaceConfig.workspaceFolder}`,
265
                        excludePatterns: workspaceConfig.excludePatterns,
266
                        enableThreading: workspaceConfig.enableThreading
267
                    }));
268
                })
269
            )).flat(1);
270

271
            //filter the project paths to only include those that are allowed by the path filterer
272
            projectConfigs = this.pathFilterer.filter(projectConfigs, x => x.projectPath);
60✔
273

274
            //delete projects not represented in the list
275
            for (const project of this.projects) {
60✔
276
                //we can't find this existing project in our new list, so scrap it
277
                if (!projectConfigs.find(x => x.projectPath === project.projectPath)) {
11✔
278
                    this.removeProject(project);
5✔
279
                }
280
            }
281

282
            // skip projects we already have (they're already loaded...no need to reload them)
283
            projectConfigs = projectConfigs.filter(x => {
60✔
284
                return !this.hasProject(x.projectPath);
68✔
285
            });
286

287
            //dedupe by project path
288
            projectConfigs = [
60✔
289
                ...projectConfigs.reduce(
290
                    (acc, x) => acc.set(x.projectPath, x),
63✔
291
                    new Map<string, typeof projectConfigs[0]>()
292
                ).values()
293
            ];
294

295
            //create missing projects
296
            await Promise.all(
60✔
297
                projectConfigs.map(async (config) => {
298
                    await this.createAndActivateProject(config);
62✔
299
                })
300
            );
301

302
            //mark that we've completed our first sync
303
            this.firstSync.tryResolve();
60✔
304
        })();
305

306
        //return the sync promise
307
        return this.syncPromise;
60✔
308
    }
309

310
    private fileChangesQueue = new ActionQueue({
84✔
311
        maxActionDuration: 45_000
312
    });
313

314
    public handleFileChanges(changes: FileChange[]) {
315
        this.logger.debug('handleFileChanges', changes.map(x => `${FileChangeType[x.type]}: ${x.srcPath}`));
69✔
316

317
        //this function should NOT be marked as async, because typescript wraps the body in an async call sometimes. These need to be registered synchronously
318
        return this.fileChangesQueue.run(async (changes) => {
60✔
319
            //wait for any pending syncs to finish
320
            await this.onInitialized();
60✔
321

322
            return this._handleFileChanges(changes);
60✔
323
        }, changes);
324
    }
325

326
    /**
327
     * Handle when files or directories are added, changed, or deleted in the workspace.
328
     * This is safe to call any time. Changes will be queued and flushed at the correct times
329
     */
330
    public async _handleFileChanges(changes: FileChange[]) {
331
        //filter any changes that are not allowed by the path filterer
332
        changes = this.pathFilterer.filter(changes, x => x.srcPath);
60✔
333

334
        this.logger.debug('handleFileChanges -> filtered', changes.map(x => `${FileChangeType[x.type]}: ${x.srcPath}`));
68✔
335

336
        //process all file changes in parallel
337
        await Promise.all(changes.map(async (change) => {
60✔
338
            await this.handleFileChange(change);
68✔
339
        }));
340
    }
341

342
    /**
343
     * Handle a single file change. If the file is a directory, this will recursively read all files in the directory and call `handleFileChanges` again
344
     */
345
    private async handleFileChange(change: FileChange) {
346
        const srcPath = util.standardizePath(change.srcPath);
72✔
347
        if (change.type === FileChangeType.Deleted) {
72✔
348
            //mark this document or directory as deleted
349
            this.documentManager.delete(srcPath);
1✔
350

351
            //file added or changed
352
        } else {
353
            //if this is a new directory, read all files recursively and register those as file changes too
354
            if (util.isDirectorySync(srcPath)) {
71✔
355
                const files = await fastGlob('**/*', {
2✔
356
                    cwd: change.srcPath,
357
                    onlyFiles: true,
358
                    absolute: true
359
                });
360
                //pipe all files found recursively in the new directory through this same function so they can be processed correctly
361
                await Promise.all(files.map((srcPath) => {
2✔
362
                    return this.handleFileChange({
6✔
363
                        srcPath: srcPath,
364
                        type: FileChangeType.Changed,
365
                        allowStandaloneProject: change.allowStandaloneProject
366
                    });
367
                }));
368

369
                //this is a new file. set the file contents
370
            } else {
371
                this.documentManager.set({
69✔
372
                    srcPath: change.srcPath,
373
                    fileContents: change.fileContents,
374
                    allowStandaloneProject: change.allowStandaloneProject
375
                });
376
            }
377
        }
378

379
        //reload any projects whose bsconfig.json was changed
380
        const projectsToReload = this.projects.filter(x => x.bsconfigPath?.toLowerCase() === change.srcPath.toLowerCase());
78✔
381
        await Promise.all(
72✔
382
            projectsToReload.map(x => this.reloadProject(x))
2✔
383
        );
384
    }
385

386
    /**
387
     * Handle when a file is closed in the editor (this mostly just handles removing standalone projects)
388
     */
389
    public async handleFileClose(event: { srcPath: string }) {
390
        this.removeStandaloneProject(event.srcPath);
1✔
391
        //most other methods on this class are async, might as well make this one async too for consistency and future expansion
392
        await Promise.resolve();
1✔
393
    }
394

395
    /**
396
     * Given a project, forcibly reload it by removing it and re-adding it
397
     */
398
    private async reloadProject(project: LspProject) {
399
        this.logger.log('Reloading project', { projectPath: project.projectPath });
2✔
400

401
        this.removeProject(project);
2✔
402
        project = await this.createAndActivateProject(project.activateOptions);
2✔
403
    }
404

405
    /**
406
     * Get all the semantic tokens for the given file
407
     * @returns an array of semantic tokens
408
     */
409
    @TrackBusyStatus
410
    public async getSemanticTokens(options: { srcPath: string }) {
1✔
411
        //wait for all pending syncs to finish
412
        await this.onIdle();
1✔
413

414
        let result = await util.promiseRaceMatch(
1✔
415
            this.projects.map(x => x.getSemanticTokens(options)),
1✔
416
            //keep the first non-falsey result
417
            (result) => result?.length > 0
1!
418
        );
419
        return result;
1✔
420
    }
421

422
    /**
423
     * Get a string containing the transpiled contents of the file at the given path
424
     * @returns the transpiled contents of the file as a string
425
     */
426
    @TrackBusyStatus
427
    public async transpileFile(options: { srcPath: string }) {
1✔
428
        //wait for all pending syncs to finish
429
        await this.onIdle();
2✔
430

431
        let result = await util.promiseRaceMatch(
2✔
432
            this.projects.map(x => x.transpileFile(options)),
2✔
433
            //keep the first non-falsey result
434
            (result) => !!result
2✔
435
        );
436
        return result;
2✔
437
    }
438

439
    /**
440
     *  Get the completions for the given position in the file
441
     */
442
    @TrackBusyStatus
443
    public async getCompletions(options: { srcPath: string; position: Position; cancellationToken?: CancellationToken }): Promise<CompletionList> {
1✔
444
        await this.onIdle();
1✔
445

446
        //if the request has been cancelled since originally requested due to idle time being slow, skip the rest of the wor
447
        if (options?.cancellationToken?.isCancellationRequested) {
1!
NEW
448
            this.logger.log('ProjectManager getCompletions cancelled', options);
×
NEW
449
            return;
×
450
        }
451

452
        this.logger.log('ProjectManager getCompletions', options);
1✔
453
        //Ask every project for results, keep whichever one responds first that has a valid response
454
        let result = await util.promiseRaceMatch(
1✔
455
            this.projects.map(x => x.getCompletions(options)),
1✔
456
            //keep the first non-falsey result
457
            (result) => result?.items?.length > 0
1!
458
        );
459
        return result;
1✔
460
    }
461

462
    /**
463
     * Get the hover information for the given position in the file. If multiple projects have hover information, the projects will be raced and
464
     * the fastest result will be returned
465
     * @returns the hover information or undefined if no hover information was found
466
     */
467
    @TrackBusyStatus
468
    public async getHover(options: { srcPath: string; position: Position }): Promise<Hover> {
1✔
469
        //wait for all pending syncs to finish
NEW
470
        await this.onIdle();
×
471

472
        //Ask every project for hover info, keep whichever one responds first that has a valid response
NEW
473
        let hover = await util.promiseRaceMatch(
×
NEW
474
            this.projects.map(x => x.getHover(options)),
×
475
            //keep the first set of non-empty results
NEW
476
            (result) => result?.length > 0
×
477
        );
NEW
478
        return hover?.[0];
×
479
    }
480

481
    /**
482
     * Get the definition for the symbol at the given position in the file
483
     * @returns a list of locations where the symbol under the position is defined in the project
484
     */
485
    @TrackBusyStatus
486
    public async getDefinition(options: { srcPath: string; position: Position }): Promise<Location[]> {
1✔
487
        //wait for all pending syncs to finish
488
        await this.onIdle();
5✔
489

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

492
        //Ask every project for definition info, keep whichever one responds first that has a valid response
493
        let result = await util.promiseRaceMatch(
5✔
494
            this.projects.map(x => x.getDefinition(options)),
5✔
495
            //keep the first non-falsey result
496
            (result) => !!result
5✔
497
        );
498
        return result;
5✔
499
    }
500

501
    @TrackBusyStatus
502
    public async getSignatureHelp(options: { srcPath: string; position: Position }): Promise<SignatureHelp> {
1✔
503
        //wait for all pending syncs to finish
504
        await this.onIdle();
4✔
505

506
        //Ask every project for definition info, keep whichever one responds first that has a valid response
507
        let signatures = await util.promiseRaceMatch(
4✔
508
            this.projects.map(x => x.getSignatureHelp(options)),
4✔
509
            //keep the first non-falsey result
510
            (result) => !!result
4✔
511
        );
512

513
        if (signatures?.length > 0) {
4!
514
            const activeSignature = signatures.length > 0 ? 0 : undefined;
3!
515

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

518
            let result: SignatureHelp = {
3✔
519
                signatures: signatures.map((s) => s.signature),
3✔
520
                activeSignature: activeSignature,
521
                activeParameter: activeParameter
522
            };
523
            return result;
3✔
524
        }
525
    }
526

527
    @TrackBusyStatus
528
    public async getDocumentSymbol(options: { srcPath: string }): Promise<DocumentSymbol[]> {
1✔
529
        //wait for all pending syncs to finish
530
        await this.onIdle();
6✔
531

532
        //Ask every project for definition info, keep whichever one responds first that has a valid response
533
        let result = await util.promiseRaceMatch(
6✔
534
            this.projects.map(x => x.getDocumentSymbol(options)),
6✔
535
            //keep the first non-falsey result
536
            (result) => !!result
6✔
537
        );
538
        return result;
6✔
539
    }
540

541
    @TrackBusyStatus
542
    public async getWorkspaceSymbol(): Promise<WorkspaceSymbol[]> {
1✔
543
        //wait for all pending syncs to finish
544
        await this.onIdle();
4✔
545

546
        //Ask every project for definition info, keep whichever one responds first that has a valid response
547
        let responses = await Promise.allSettled(
4✔
548
            this.projects.map(x => x.getWorkspaceSymbol())
4✔
549
        );
550
        let results = responses
4✔
551
            //keep all symbol results
552
            .map((x) => {
553
                return x.status === 'fulfilled' ? x.value : [];
4!
554
            })
555
            //flatten the array
556
            .flat()
557
            //throw out nulls
558
            .filter(x => !!x);
24✔
559

560
        // Remove duplicates
561
        const allSymbols = Object.values(
4✔
562
            results.reduce((map, symbol) => {
563
                const key = symbol.location.uri + symbol.name;
24✔
564
                map[key] = symbol;
24✔
565
                return map;
24✔
566
            }, {})
567
        );
568

569
        return allSymbols as SymbolInformation[];
4✔
570
    }
571

572
    @TrackBusyStatus
573
    public async getReferences(options: { srcPath: string; position: Position }): Promise<Location[]> {
1✔
574
        //wait for all pending syncs to finish
575
        await this.onIdle();
3✔
576

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

586
    @TrackBusyStatus
587
    public async getCodeActions(options: { srcPath: string; range: Range }) {
1✔
588
        //wait for all pending syncs to finish
NEW
589
        await this.onIdle();
×
590

591
        //Ask every project for definition info, keep whichever one responds first that has a valid response
NEW
592
        let result = await util.promiseRaceMatch(
×
NEW
593
            this.projects.map(x => x.getCodeActions(options)),
×
594
            //keep the first non-falsey result
NEW
595
            (result) => !!result
×
596
        );
NEW
597
        return result;
×
598
    }
599

600
    /**
601
     * Scan a given workspace for all `bsconfig.json` files. If at least one is found, then only folders who have bsconfig.json are returned.
602
     * If none are found, then the workspaceFolder itself is treated as a project
603
     */
604
    private async getProjectPaths(workspaceConfig: WorkspaceConfig) {
605
        //get the list of exclude patterns, and negate them (so they actually work like excludes)
606
        const excludePatterns = (workspaceConfig.excludePatterns ?? []).map(x => s`!${x}`);
59✔
607
        let files = await rokuDeploy.getFilePaths([
59✔
608
            '**/bsconfig.json',
609
            //exclude all files found in `files.exclude`
610
            ...excludePatterns
611
        ], workspaceConfig.workspaceFolder);
612

613
        //filter the files to only include those that are allowed by the path filterer
614
        files = this.pathFilterer.filter(files, x => x.src);
59✔
615

616
        //if we found at least one bsconfig.json, then ALL projects must have a bsconfig.json.
617
        if (files.length > 0) {
59✔
618
            return files.map(file => s`${path.dirname(file.src)}`);
29✔
619
        }
620

621
        //look for roku project folders
622
        let rokuLikeDirs = (await Promise.all(
38✔
623
            //find all folders containing a `manifest` file
624
            (await rokuDeploy.getFilePaths([
625
                '**/manifest',
626
                ...excludePatterns
627

628
                //is there at least one .bs|.brs file under the `/source` folder?
629
            ], workspaceConfig.workspaceFolder)).map(async manifestEntry => {
630
                const manifestDir = path.dirname(manifestEntry.src);
5✔
631
                const files = await rokuDeploy.getFilePaths([
5✔
632
                    'source/**/*.{brs,bs}',
633
                    ...excludePatterns
634
                ], manifestDir);
635
                if (files.length > 0) {
5✔
636
                    return manifestDir;
3✔
637
                }
638
            })
639
            //throw out nulls
640
        )).filter(x => !!x);
5✔
641

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

645
        if (rokuLikeDirs.length > 0) {
38✔
646
            return rokuLikeDirs;
2✔
647
        }
648

649
        //treat the workspace folder as a brightscript project itself
650
        return [workspaceConfig.workspaceFolder];
36✔
651
    }
652

653
    /**
654
     * Returns true if we have this project, or false if we don't
655
     * @param projectPath path to the project
656
     * @returns true if the project exists, or false if it doesn't
657
     */
658
    private hasProject(projectPath: string) {
659
        return !!this.getProject(projectPath);
204✔
660
    }
661

662
    /**
663
     * Get a project with the specified path
664
     * @param param path to the project or an obj that has `projectPath` prop
665
     * @returns a project, or undefined if no project was found
666
     */
667
    private getProject(param: string | { projectPath: string }) {
668
        const projectPath = util.standardizePath(
206✔
669
            (typeof param === 'string') ? param : param.projectPath
206✔
670
        );
671
        return this.projects.find(x => x.projectPath === projectPath);
206✔
672
    }
673

674
    /**
675
     * Remove a project from the language server
676
     */
677
    private removeProject(project: LspProject) {
678
        const idx = this.projects.findIndex(x => x.projectPath === project?.projectPath);
12✔
679
        if (idx > -1) {
12✔
680
            this.logger.log('Removing project', { projectPath: project.projectPath, projectNumber: project.projectNumber });
9✔
681
            this.projects.splice(idx, 1);
9✔
682
        }
683
        //anytime we remove a project, we should emit an event that clears all of its diagnostics
684
        this.emit('diagnostics', { project: project, diagnostics: [] });
12✔
685
        project?.dispose();
12✔
686
        this.busyStatusTracker.endAllRunsForScope(project);
12✔
687
    }
688

689
    /**
690
     * A unique project counter to help distinguish log entries in lsp mode
691
     */
692
    private static projectNumberSequence = 0;
1✔
693

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

696
    /**
697
     * Get a projectNumber for a given config. Try to reuse project numbers when we've seen this project before
698
     *  - If the config already has one, use that.
699
     *  - If we've already seen this config before, use the same project number as before
700
     */
701
    private getProjectNumber(config: ProjectConfig) {
702
        if (config.projectNumber !== undefined) {
69✔
703
            return config.projectNumber;
6✔
704
        }
705
        return ProjectManager.projectNumberCache.getOrAdd(`${s(config.projectPath)}-${s(config.workspaceFolder)}-${config.bsconfigPath}`, () => {
63✔
706
            return ProjectManager.projectNumberSequence++;
11✔
707
        });
708
    }
709

710
    /**
711
     * Constructs a project for the given config. Just makes the project, doesn't activate it
712
     * @returns a new project, or the existing project if one already exists with this config info
713
     */
714
    private constructProject(config: ProjectConfig): LspProject {
715
        //skip this project if we already have it
716
        if (this.hasProject(config.projectPath)) {
69!
NEW
717
            return this.getProject(config.projectPath);
×
718
        }
719

720
        config.projectNumber = this.getProjectNumber(config);
69✔
721
        const projectIdentifier = `prj${config.projectNumber}`;
69✔
722

723
        let project: LspProject = config.enableThreading
69✔
724
            ? new WorkerThreadProject({
69✔
725
                logger: this.logger.createLogger(),
726
                projectIdentifier: projectIdentifier
727
            })
728
            : new Project({
729
                logger: this.logger.createLogger(),
730
                projectIdentifier: projectIdentifier
731
            });
732

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

735
        this.projects.push(project);
69✔
736

737
        //pipe all project-specific events through our emitter, and include the project reference
738
        project.on('all', (eventName, data) => {
69✔
739
            this.emit(eventName as any, {
245✔
740
                ...data,
741
                project: project
742
            } as any);
743
        });
744
        return project;
69✔
745
    }
746

747
    /**
748
     * Constructs a project for the given config
749
     * @returns a new project, or the existing project if one already exists with this config info
750
     */
751
    @TrackBusyStatus
752
    private async createAndActivateProject(config: ProjectConfig): Promise<LspProject> {
1✔
753
        //skip this project if we already have it
754
        if (this.hasProject(config.projectPath)) {
67✔
755
            return this.getProject(config.projectPath);
1✔
756
        }
757
        const project = this.constructProject(config);
66✔
758
        await this.activateProject(project, config);
66✔
759
        return project;
65✔
760
    }
761

762
    @TrackBusyStatus
763
    private async activateProject(project: LspProject, config: ProjectConfig) {
1✔
764
        await project.activate(config);
69✔
765

766
        //send an event to indicate that this project has been activated
767
        this.emit('project-activate', { project: project });
68✔
768

769
        //register this project's list of files with the path filterer
770
        const unregister = this.pathFilterer.registerIncludeList(project.rootDir, project.filePatterns);
68✔
771
        project.disposables.push({ dispose: unregister });
68✔
772
    }
773

774
    public on(eventName: 'validate-begin', handler: (data: { project: LspProject }) => MaybePromise<void>);
775
    public on(eventName: 'validate-end', handler: (data: { project: LspProject }) => MaybePromise<void>);
776
    public on(eventName: 'critical-failure', handler: (data: { project: LspProject; message: string }) => MaybePromise<void>);
777
    public on(eventName: 'project-activate', handler: (data: { project: LspProject }) => MaybePromise<void>);
778
    public on(eventName: 'diagnostics', handler: (data: { project: LspProject; diagnostics: LspDiagnostic[] }) => MaybePromise<void>);
779
    public on(eventName: string, handler: (payload: any) => MaybePromise<void>) {
780
        this.emitter.on(eventName, handler as any);
307✔
781
        return () => {
307✔
782
            this.emitter.removeListener(eventName, handler as any);
1✔
783
        };
784
    }
785

786
    private emit(eventName: 'validate-begin', data: { project: LspProject });
787
    private emit(eventName: 'validate-end', data: { project: LspProject });
788
    private emit(eventName: 'critical-failure', data: { project: LspProject; message: string });
789
    private emit(eventName: 'project-activate', data: { project: LspProject });
790
    private emit(eventName: 'diagnostics', data: { project: LspProject; diagnostics: LspDiagnostic[] });
791
    private async emit(eventName: string, data?) {
792
        //emit these events on next tick, otherwise they will be processed immediately which could cause issues
793
        await util.sleep(0);
330✔
794
        this.emitter.emit(eventName, data);
330✔
795
    }
796
    private emitter = new EventEmitter();
84✔
797

798
    public dispose() {
799
        this.emitter.removeAllListeners();
84✔
800
        for (const project of this.projects) {
84✔
801
            project?.dispose?.();
61!
802
        }
803
    }
804
}
805

806
export interface WorkspaceConfig {
807
    /**
808
     * Absolute path to the folder where the workspace resides
809
     */
810
    workspaceFolder: string;
811
    /**
812
     * A list of glob patterns used to _exclude_ files from various bsconfig searches
813
     */
814
    excludePatterns?: string[];
815
    /**
816
     * 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
817
     */
818
    bsconfigPath?: string;
819
    /**
820
     * Should the projects in this workspace be run in their own dedicated worker threads, or all run on the main thread
821
     */
822
    enableThreading?: boolean;
823
}
824

825
interface StandaloneProject extends LspProject {
826
    /**
827
     * The path to the file that this project represents
828
     */
829
    srcPath: string;
830
}
831

832
/**
833
 * An annotation used to wrap the method in a busyStatus tracking call
834
 */
835
function TrackBusyStatus(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
836
    let originalMethod = descriptor.value;
14✔
837

838
    //wrapping the original method
839
    descriptor.value = function value(this: ProjectManager, ...args: any[]) {
14✔
840
        return this.busyStatusTracker.run(() => {
259✔
841
            return originalMethod.apply(this, args);
259✔
842
        }, originalMethod.name);
843
    };
844
}
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