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

rokucommunity / brighterscript / #13868

13 Jun 2024 01:42AM UTC coverage: 88.744% (-0.3%) from 89.045%
#13868

push

TwitchBronBron
Fix broken tests

6551 of 7839 branches covered (83.57%)

Branch coverage included in aggregate %.

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

213 existing lines in 11 files now uncovered.

9423 of 10161 relevant lines covered (92.74%)

1667.54 hits per line

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

88.1
/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 } 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 { LogLevel, createLogger } from '../logging';
1✔
19
import { Trace } from '../common/Decorators';
1✔
20
import { Cache } from '../Cache';
1✔
21
import { ActionQueue } from './ActionQueue';
1✔
22

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

41
        this.on('validate-begin', (event) => {
69✔
42
            this.busyStatusTracker.beginScopedRun(event.project, `validate-project-${event.project.projectNumber}`);
72✔
43
        });
44
        this.on('validate-end', (event) => {
69✔
45
            void this.busyStatusTracker.endScopedRun(event.project, `validate-project-${event.project.projectNumber}`);
69✔
46
        });
47
    }
48

49
    private pathFilterer: PathFilterer;
50

51
    private logger: Logger;
52

53
    /**
54
     * Collection of all projects
55
     */
56
    public projects: LspProject[] = [];
69✔
57

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

64
    private documentManager: DocumentManager;
65
    public static documentManagerDelay = 150;
1✔
66

67
    public busyStatusTracker = new BusyStatusTracker();
69✔
68

69
    /**
70
     * Apply all of the queued document changes. This should only be called as a result of the documentManager flushing changes, and never called manually
71
     * @param event the document changes that have occurred since the last time we applied
72
     */
73
    @TrackBusyStatus
74
    private async flushDocumentChanges(event: FlushEvent) {
1✔
75
        this.logger.log('flushDocumentChanges', event.actions.map(x => x.srcPath));
26✔
76
        //ensure that we're fully initialized before proceeding
77
        await this.onInitialized();
14✔
78

79
        const actions = [...event.actions] as DocumentActionWithStatus[];
14✔
80

81
        let idSequence = 0;
14✔
82
        //add an ID to every action (so we can track which actions were handled by which projects)
83
        for (const action of actions) {
14✔
84
            action.id = idSequence++;
26✔
85
        }
86

87
        console.info(`Flushing ${actions.length} document changes`, actions.map(x => ({
26✔
88
            type: x.type,
89
            srcPath: x.srcPath
90
        })));
91

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

97
            const filterer = new PathCollection({
14✔
98
                rootDir: project.rootDir,
99
                globs: project.filePatterns
100
            });
101
            // only include files that are applicable to this specific project (still allow deletes to flow through since they're cheap)
102
            const projectActions = actions.filter(action => {
14✔
103
                return action.type === 'delete' || filterer.isMatch(action.srcPath);
29✔
104
            });
105
            return project.applyFileChanges(projectActions);
14✔
106
        }));
107

108
        //create standalone projects for any files not handled by any project
109
        const flatResponses = responses.flat();
14✔
110
        for (const action of actions) {
14✔
111
            //skip this action if it doesn't support standalone projects
112
            if (!action.allowStandaloneProject || action.type === 'delete') {
26✔
113
                continue;
24✔
114
            }
115

116
            // create a standalone project if this action was handled by zero projects and was a 'set' operation
117
            const wasHandled = flatResponses.some(x => x.id === action.id && action.type === 'set');
2✔
118
            if (wasHandled === false) {
2✔
119
                await this.createStandaloneProject(action.srcPath);
1✔
120
            }
121
        }
122
        this.logger.log('flushDocumentChanges complete', event.actions.map(x => x.srcPath));
26✔
123
    }
124

125
    /**
126
     * Create a project that validates a single file. This is useful for getting language support for files that don't belong to a project
127
     */
128
    private async createStandaloneProject(srcPath: string) {
129
        srcPath = util.standardizePath(srcPath);
1✔
130

131
        this.logger.log(`Creating standalone project for '${srcPath}'`);
1✔
132

133
        const projectNumber = ProjectManager.projectNumberSequence++;
1✔
134
        const rootDir = path.join(__dirname, `standalone-project-${projectNumber}`);
1✔
135
        const projectOptions = {
1✔
136
            //these folders don't matter for standalone projects
137
            workspaceFolder: rootDir,
138
            projectPath: rootDir,
139
            enableThreading: false,
140
            projectNumber: projectNumber,
141
            files: [{
142
                src: srcPath,
143
                dest: 'source/standalone.brs'
144
            }]
145
        };
146
        const project = this.constructProject(projectOptions) as StandaloneProject;
1✔
147
        project.srcPath = srcPath;
1✔
148
        this.standaloneProjects.push(project);
1✔
149
        await this.activateProject(project, projectOptions);
1✔
150
    }
151

152
    private removeStandaloneProject(srcPath: string) {
153
        srcPath = util.standardizePath(srcPath);
1✔
154
        //remove all standalone projects that have this srcPath
155
        for (let i = this.standaloneProjects.length - 1; i >= 0; i--) {
1✔
156
            const project = this.standaloneProjects[i];
1✔
157
            if (project.srcPath === srcPath) {
1!
158
                this.removeProject(project);
1✔
159
                this.standaloneProjects.splice(i, 1);
1✔
160
            }
161
        }
162
    }
163

164
    /**
165
     * A promise that's set when a sync starts, and resolved when the sync is complete
166
     */
167
    private syncPromise: Promise<void> | undefined;
168
    private firstSync = new Deferred();
69✔
169

170
    /**
171
     * Get a promise that resolves when this manager is finished initializing
172
     */
173
    public onInitialized() {
174
        return Promise.allSettled([
61✔
175
            //wait for the first sync to finish
176
            this.firstSync.promise,
177
            //make sure we're not in the middle of a sync
178
            this.syncPromise,
179
            //make sure all projects are activated
180
            ...this.projects.map(x => x.whenActivated())
61✔
181
        ]);
182
    }
183
    /**
184
     * Get a promise that resolves when the project manager is idle (no pending work)
185
     */
186
    public async onIdle() {
187
        await this.onInitialized();
30✔
188

189
        //There are race conditions where the fileChangesQueue will become idle, but that causes the documentManager
190
        //to start a new flush. So we must keep waiting until everything is idle
191
        while (!this.documentManager.isIdle || !this.fileChangesQueue.isIdle) {
30✔
192
            await Promise.allSettled([
8✔
193
                //make sure all pending file changes have been flushed
194
                this.documentManager.onIdle(),
195
                //wait for the file changes queue to be idle
196
                this.fileChangesQueue.onIdle()
197
            ]);
198
        }
199
    }
200

201
    /**
202
     * Given a list of all desired projects, create any missing projects and destroy and projects that are no longer available
203
     * Treat workspaces that don't have a bsconfig.json as a project.
204
     * Handle situations where bsconfig.json files were added or removed (to elevate/lower workspaceFolder projects accordingly)
205
     * Leave existing projects alone if they are not affected by these changes
206
     * @param workspaceConfigs an array of workspaces
207
     */
208
    @TrackBusyStatus
209
    public async syncProjects(workspaceConfigs: WorkspaceConfig[], forceReload = false) {
1✔
210
        //if we're force reloading, destroy all projects and start fresh
211
        if (forceReload) {
56!
UNCOV
212
            this.logger.log('Force reloading all projects');
×
UNCOV
213
            for (const project of this.projects) {
×
UNCOV
214
                this.removeProject(project);
×
215
            }
216
        }
217

218
        this.syncPromise = (async () => {
56✔
219
            //build a list of unique projects across all workspace folders
220
            let projectConfigs = (await Promise.all(
56✔
221
                workspaceConfigs.map(async workspaceConfig => {
222
                    const projectPaths = await this.getProjectPaths(workspaceConfig);
55✔
223
                    return projectPaths.map(projectPath => ({
64✔
224
                        projectPath: s`${projectPath}`,
225
                        workspaceFolder: s`${workspaceConfig.workspaceFolder}`,
226
                        excludePatterns: workspaceConfig.excludePatterns,
227
                        enableThreading: workspaceConfig.enableThreading
228
                    }));
229
                })
230
            )).flat(1);
231

232
            //filter the project paths to only include those that are allowed by the path filterer
233
            projectConfigs = this.pathFilterer.filter(projectConfigs, x => x.projectPath);
56✔
234

235
            //delete projects not represented in the list
236
            for (const project of this.projects) {
56✔
237
                //we can't find this existing project in our new list, so scrap it
238
                if (!projectConfigs.find(x => x.projectPath === project.projectPath)) {
10✔
239
                    this.removeProject(project);
4✔
240
                }
241
            }
242

243
            // skip projects we already have (they're already loaded...no need to reload them)
244
            projectConfigs = projectConfigs.filter(x => {
56✔
245
                return !this.hasProject(x.projectPath);
64✔
246
            });
247

248
            //dedupe by project path
249
            projectConfigs = [
56✔
250
                ...projectConfigs.reduce(
251
                    (acc, x) => acc.set(x.projectPath, x),
59✔
252
                    new Map<string, typeof projectConfigs[0]>()
253
                ).values()
254
            ];
255

256
            //create missing projects
257
            await Promise.all(
56✔
258
                projectConfigs.map(async (config) => {
259
                    await this.createAndActivateProject(config);
58✔
260
                })
261
            );
262

263
            //mark that we've completed our first sync
264
            this.firstSync.tryResolve();
56✔
265
        })();
266

267
        //return the sync promise
268
        return this.syncPromise;
56✔
269
    }
270

271
    private fileChangesQueue = new ActionQueue({
69✔
272
        maxActionDuration: 45_000
273
    });
274

275
    public handleFileChanges(changes: FileChange[]) {
276
        this.logger.log('handleFileChanges', changes.map(x => x.srcPath));
26✔
277
        //this function should NOT be marked as async, because typescript wraps the body in an async call sometimes. These need to be registered synchronously
278
        return this.fileChangesQueue.run(async (changes) => {
17✔
279
            this.logger.log('handleFileChanges -> run', changes.map(x => x.srcPath));
26✔
280
            //wait for any pending syncs to finish
281
            await this.onInitialized();
17✔
282

283
            return this._handleFileChanges(changes);
17✔
284
        }, changes);
285
    }
286

287
    /**
288
     * Handle when files or directories are added, changed, or deleted in the workspace.
289
     * This is safe to call any time. Changes will be queued and flushed at the correct times
290
     */
291
    public async _handleFileChanges(changes: FileChange[]) {
292
        //filter any changes that are not allowed by the path filterer
293
        changes = this.pathFilterer.filter(changes, x => x.srcPath);
17✔
294

295
        //process all file changes in parallel
296
        await Promise.all(changes.map(async (change) => {
17✔
297
            await this.handleFileChange(change);
25✔
298
        }));
299
    }
300

301
    /**
302
     * Handle a single file change. If the file is a directory, this will recursively read all files in the directory and call `handleFileChanges` again
303
     */
304
    private async handleFileChange(change: FileChange) {
305
        const srcPath = util.standardizePath(change.srcPath);
29✔
306
        if (change.type === FileChangeType.Deleted) {
29✔
307
            //mark this document or directory as deleted
308
            this.documentManager.delete(srcPath);
1✔
309

310
            //file added or changed
311
        } else {
312
            //if this is a new directory, read all files recursively and register those as file changes too
313
            if (util.isDirectorySync(srcPath)) {
28✔
314
                const files = await fastGlob('**/*', {
2✔
315
                    cwd: change.srcPath,
316
                    onlyFiles: true,
317
                    absolute: true
318
                });
319
                //pipe all files found recursively in the new directory through this same function so they can be processed correctly
320
                await Promise.all(files.map((srcPath) => {
2✔
321
                    return this.handleFileChange({
6✔
322
                        srcPath: srcPath,
323
                        type: FileChangeType.Changed,
324
                        allowStandaloneProject: change.allowStandaloneProject
325
                    });
326
                }));
327

328
                //this is a new file. set the file contents
329
            } else {
330
                this.documentManager.set({
26✔
331
                    srcPath: change.srcPath,
332
                    fileContents: change.fileContents,
333
                    allowStandaloneProject: change.allowStandaloneProject
334
                });
335
            }
336
        }
337

338
        //reload any projects whose bsconfig.json was changed
339
        const projectsToReload = this.projects.filter(x => x.bsconfigPath?.toLowerCase() === change.srcPath.toLowerCase());
32✔
340
        await Promise.all(
29✔
UNCOV
341
            projectsToReload.map(x => this.reloadProject(x))
×
342
        );
343
    }
344

345
    /**
346
     * Handle when a file is closed in the editor (this mostly just handles removing standalone projects)
347
     */
348
    public async handleFileClose(event: { srcPath: string }) {
349
        this.removeStandaloneProject(event.srcPath);
1✔
350
        //most other methods on this class are async, might as well make this one async too for consistency and future expansion
351
        await Promise.resolve();
1✔
352
    }
353

354
    /**
355
     * Given a project, forcibly reload it by removing it and re-adding it
356
     */
357
    private async reloadProject(project: LspProject) {
UNCOV
358
        this.removeProject(project);
×
UNCOV
359
        project = await this.createAndActivateProject(project.activateOptions);
×
UNCOV
360
        this.emit('project-reload', { project: project });
×
361
    }
362

363
    /**
364
     * Get all the semantic tokens for the given file
365
     * @returns an array of semantic tokens
366
     */
367
    @TrackBusyStatus
368
    public async getSemanticTokens(options: { srcPath: string }) {
1✔
369
        //wait for all pending syncs to finish
370
        await this.onIdle();
1✔
371

372
        let result = await util.promiseRaceMatch(
1✔
373
            this.projects.map(x => x.getSemanticTokens(options)),
1✔
374
            //keep the first non-falsey result
375
            (result) => result?.length > 0
1!
376
        );
377
        return result;
1✔
378
    }
379

380
    /**
381
     * Get a string containing the transpiled contents of the file at the given path
382
     * @returns the transpiled contents of the file as a string
383
     */
384
    @TrackBusyStatus
385
    public async transpileFile(options: { srcPath: string }) {
1✔
386
        //wait for all pending syncs to finish
387
        await this.onIdle();
2✔
388

389
        let result = await util.promiseRaceMatch(
2✔
390
            this.projects.map(x => x.transpileFile(options)),
2✔
391
            //keep the first non-falsey result
392
            (result) => !!result
2✔
393
        );
394
        return result;
2✔
395
    }
396

397
    /**
398
     *  Get the completions for the given position in the file
399
     */
400
    @TrackBusyStatus
401
    public async getCompletions(options: { srcPath: string; position: Position }): Promise<CompletionList> {
1✔
402
        await this.onIdle();
1✔
403

404
        this.logger.log('ProjectManager getCompletions', options);
1✔
405
        //Ask every project for results, keep whichever one responds first that has a valid response
406
        let result = await util.promiseRaceMatch(
1✔
407
            this.projects.map(x => x.getCompletions(options)),
1✔
408
            //keep the first non-falsey result
409
            (result) => result?.items?.length > 0
1!
410
        );
411
        return result;
1✔
412
    }
413

414
    /**
415
     * Get the hover information for the given position in the file. If multiple projects have hover information, the projects will be raced and
416
     * the fastest result will be returned
417
     * @returns the hover information or undefined if no hover information was found
418
     */
419
    @TrackBusyStatus
420
    public async getHover(options: { srcPath: string; position: Position }): Promise<Hover> {
1✔
421
        //wait for all pending syncs to finish
UNCOV
422
        await this.onIdle();
×
423

424
        //Ask every project for hover info, keep whichever one responds first that has a valid response
UNCOV
425
        let hover = await util.promiseRaceMatch(
×
UNCOV
426
            this.projects.map(x => x.getHover(options)),
×
427
            //keep the first set of non-empty results
UNCOV
428
            (result) => result?.length > 0
×
429
        );
UNCOV
430
        return hover?.[0];
×
431
    }
432

433
    /**
434
     * Get the definition for the symbol at the given position in the file
435
     * @returns a list of locations where the symbol under the position is defined in the project
436
     */
437
    @TrackBusyStatus
438
    public async getDefinition(options: { srcPath: string; position: Position }): Promise<Location[]> {
1✔
439
        //wait for all pending syncs to finish
440
        await this.onIdle();
5✔
441

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

444
        //Ask every project for definition info, keep whichever one responds first that has a valid response
445
        let result = await util.promiseRaceMatch(
5✔
446
            this.projects.map(x => x.getDefinition(options)),
5✔
447
            //keep the first non-falsey result
448
            (result) => !!result
5✔
449
        );
450
        return result;
5✔
451
    }
452

453
    @TrackBusyStatus
454
    public async getSignatureHelp(options: { srcPath: string; position: Position }): Promise<SignatureHelp> {
1✔
455
        //wait for all pending syncs to finish
456
        await this.onIdle();
4✔
457

458
        //Ask every project for definition info, keep whichever one responds first that has a valid response
459
        let signatures = await util.promiseRaceMatch(
4✔
460
            this.projects.map(x => x.getSignatureHelp(options)),
4✔
461
            //keep the first non-falsey result
462
            (result) => !!result
4✔
463
        );
464

465
        if (signatures?.length > 0) {
4✔
466
            const activeSignature = signatures.length > 0 ? 0 : undefined;
3!
467

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

470
            let result: SignatureHelp = {
3✔
471
                signatures: signatures.map((s) => s.signature),
3✔
472
                activeSignature: activeSignature,
473
                activeParameter: activeParameter
474
            };
475
            return result;
3✔
476
        }
477
    }
478

479
    @TrackBusyStatus
480
    public async getDocumentSymbol(options: { srcPath: string }): Promise<DocumentSymbol[]> {
1✔
481
        //wait for all pending syncs to finish
482
        await this.onIdle();
6✔
483

484
        //Ask every project for definition info, keep whichever one responds first that has a valid response
485
        let result = await util.promiseRaceMatch(
6✔
486
            this.projects.map(x => x.getDocumentSymbol(options)),
6✔
487
            //keep the first non-falsey result
488
            (result) => !!result
6✔
489
        );
490
        return result;
6✔
491
    }
492

493
    @TrackBusyStatus
494
    public async getWorkspaceSymbol(): Promise<WorkspaceSymbol[]> {
1✔
495
        //wait for all pending syncs to finish
496
        await this.onIdle();
4✔
497

498
        //Ask every project for definition info, keep whichever one responds first that has a valid response
499
        let responses = await Promise.allSettled(
4✔
500
            this.projects.map(x => x.getWorkspaceSymbol())
4✔
501
        );
502
        let results = responses
4✔
503
            //keep all symbol results
504
            .map((x) => {
505
                return x.status === 'fulfilled' ? x.value : [];
4!
506
            })
507
            //flatten the array
508
            .flat()
509
            //throw out nulls
510
            .filter(x => !!x);
24✔
511

512
        // Remove duplicates
513
        const allSymbols = Object.values(
4✔
514
            results.reduce((map, symbol) => {
515
                const key = symbol.location.uri + symbol.name;
24✔
516
                map[key] = symbol;
24✔
517
                return map;
24✔
518
            }, {})
519
        );
520

521
        return allSymbols as SymbolInformation[];
4✔
522
    }
523

524
    @TrackBusyStatus
525
    public async getReferences(options: { srcPath: string; position: Position }): Promise<Location[]> {
1✔
526
        //wait for all pending syncs to finish
527
        await this.onIdle();
3✔
528

529
        //Ask every project for definition info, keep whichever one responds first that has a valid response
530
        let result = await util.promiseRaceMatch(
3✔
531
            this.projects.map(x => x.getReferences(options)),
3✔
532
            //keep the first non-falsey result
533
            (result) => !!result
3✔
534
        );
535
        return result ?? [];
3!
536
    }
537

538
    @TrackBusyStatus
539
    public async getCodeActions(options: { srcPath: string; range: Range }) {
1✔
540
        //wait for all pending syncs to finish
UNCOV
541
        await this.onIdle();
×
542

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

552
    /**
553
     * Scan a given workspace for all `bsconfig.json` files. If at least one is found, then only folders who have bsconfig.json are returned.
554
     * If none are found, then the workspaceFolder itself is treated as a project
555
     */
556
    private async getProjectPaths(workspaceConfig: WorkspaceConfig) {
557
        //get the list of exclude patterns, and negate them (so they actually work like excludes)
558
        const excludePatterns = (workspaceConfig.excludePatterns ?? []).map(x => s`!${x}`);
55✔
559
        let files = await rokuDeploy.getFilePaths([
55✔
560
            '**/bsconfig.json',
561
            //exclude all files found in `files.exclude`
562
            ...excludePatterns
563
        ], workspaceConfig.workspaceFolder);
564

565
        //filter the files to only include those that are allowed by the path filterer
566
        files = this.pathFilterer.filter(files, x => x.src);
55✔
567

568
        //if we found at least one bsconfig.json, then ALL projects must have a bsconfig.json.
569
        if (files.length > 0) {
55✔
570
            return files.map(file => s`${path.dirname(file.src)}`);
26✔
571
        }
572

573
        //look for roku project folders
574
        let rokuLikeDirs = (await Promise.all(
37✔
575
            //find all folders containing a `manifest` file
576
            (await rokuDeploy.getFilePaths([
577
                '**/manifest',
578
                ...excludePatterns
579

580
                //is there at least one .bs|.brs file under the `/source` folder?
581
            ], workspaceConfig.workspaceFolder)).map(async manifestEntry => {
582
                const manifestDir = path.dirname(manifestEntry.src);
5✔
583
                const files = await rokuDeploy.getFilePaths([
5✔
584
                    'source/**/*.{brs,bs}',
585
                    ...excludePatterns
586
                ], manifestDir);
587
                if (files.length > 0) {
5✔
588
                    return manifestDir;
3✔
589
                }
590
            })
591
            //throw out nulls
592
        )).filter(x => !!x);
5✔
593

594
        //throw out any directories that are not allowed by the path filterer
595
        rokuLikeDirs = this.pathFilterer.filter(rokuLikeDirs, srcPath => srcPath);
37✔
596

597
        if (rokuLikeDirs.length > 0) {
37✔
598
            return rokuLikeDirs;
2✔
599
        }
600

601
        //treat the workspace folder as a brightscript project itself
602
        return [workspaceConfig.workspaceFolder];
35✔
603
    }
604

605
    /**
606
     * Returns true if we have this project, or false if we don't
607
     * @param projectPath path to the project
608
     * @returns true if the project exists, or false if it doesn't
609
     */
610
    private hasProject(projectPath: string) {
611
        return !!this.getProject(projectPath);
186✔
612
    }
613

614
    /**
615
     * Get a project with the specified path
616
     * @param param path to the project or an obj that has `projectPath` prop
617
     * @returns a project, or undefined if no project was found
618
     */
619
    private getProject(param: string | { projectPath: string }) {
620
        const projectPath = util.standardizePath(
188✔
621
            (typeof param === 'string') ? param : param.projectPath
188✔
622
        );
623
        return this.projects.find(x => x.projectPath === projectPath);
188✔
624
    }
625

626
    /**
627
     * Remove a project from the language server
628
     */
629
    private removeProject(project: LspProject) {
630
        const idx = this.projects.findIndex(x => x.projectPath === project?.projectPath);
8✔
631
        if (idx > -1) {
8✔
632
            this.projects.splice(idx, 1);
5✔
633
        }
634
        project?.dispose();
8✔
635
        this.busyStatusTracker.endAllRunsForScope(project);
8✔
636
    }
637

638
    /**
639
     * A unique project counter to help distinguish log entries in lsp mode
640
     */
641
    private static projectNumberSequence = 0;
1✔
642

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

645
    /**
646
     * Get a projectNumber for a given config. Try to reuse project numbers when we've seen this project before
647
     *  - If the config already has one, use that.
648
     *  - If we've already seen this config before, use the same project number as before
649
     */
650
    private getProjectNumber(config: ProjectConfig) {
651
        if (config.projectNumber !== undefined) {
61✔
652
            return config.projectNumber;
2✔
653
        }
654
        return ProjectManager.projectNumberCache.getOrAdd(`${s(config.projectPath)}-${s(config.workspaceFolder)}-${config.bsconfigPath}`, () => {
59✔
655
            return ProjectManager.projectNumberSequence++;
11✔
656
        });
657
    }
658

659
    /**
660
     * Constructs a project for the given config. Just makes the project, doesn't activate it
661
     * @returns a new project, or the existing project if one already exists with this config info
662
     */
663
    private constructProject(config: ProjectConfig): LspProject {
664
        //skip this project if we already have it
665
        if (this.hasProject(config.projectPath)) {
61!
UNCOV
666
            return this.getProject(config.projectPath);
×
667
        }
668

669
        config.projectNumber = this.getProjectNumber(config);
61✔
670

671
        let project: LspProject = config.enableThreading
61✔
672
            ? new WorkerThreadProject({
61✔
673
                logger: this.logger.createLogger()
674
            })
675
            : new Project({
676
                logger: this.logger.createLogger()
677
            });
678

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

681
        this.projects.push(project);
61✔
682

683
        //pipe all project-specific events through our emitter, and include the project reference
684
        project.on('all', (eventName, data) => {
61✔
685
            this.emit(eventName as any, {
212✔
686
                ...data,
687
                project: project
688
            } as any);
689
        });
690
        return project;
61✔
691
    }
692

693
    /**
694
     * Constructs a project for the given config
695
     * @returns a new project, or the existing project if one already exists with this config info
696
     */
697
    @TrackBusyStatus
698
    private async createAndActivateProject(config: ProjectConfig): Promise<LspProject> {
1✔
699
        //skip this project if we already have it
700
        if (this.hasProject(config.projectPath)) {
61✔
701
            return this.getProject(config.projectPath);
1✔
702
        }
703
        const project = this.constructProject(config);
60✔
704
        await this.activateProject(project, config);
60✔
705
        return project;
59✔
706
    }
707

708
    @TrackBusyStatus
709
    private async activateProject(project: LspProject, config: ProjectConfig) {
1✔
710
        await project.activate(config);
61✔
711

712
        //register this project's list of files with the path filterer
713
        const unregister = this.pathFilterer.registerIncludeList(project.rootDir, project.filePatterns);
60✔
714
        project.disposables.push({ dispose: unregister });
60✔
715
    }
716

717
    public on(eventName: 'validate-begin', handler: (data: { project: LspProject }) => MaybePromise<void>);
718
    public on(eventName: 'validate-end', handler: (data: { project: LspProject }) => MaybePromise<void>);
719
    public on(eventName: 'critical-failure', handler: (data: { project: LspProject; message: string }) => MaybePromise<void>);
720
    public on(eventName: 'project-reload', handler: (data: { project: LspProject }) => MaybePromise<void>);
721
    public on(eventName: 'diagnostics', handler: (data: { project: LspProject; diagnostics: LspDiagnostic[] }) => MaybePromise<void>);
722
    public on(eventName: string, handler: (payload: any) => MaybePromise<void>) {
723
        this.emitter.on(eventName, handler as any);
248✔
724
        return () => {
248✔
725
            this.emitter.removeListener(eventName, handler as any);
1✔
726
        };
727
    }
728

729
    private emit(eventName: 'validate-begin', data: { project: LspProject });
730
    private emit(eventName: 'validate-end', data: { project: LspProject });
731
    private emit(eventName: 'critical-failure', data: { project: LspProject; message: string });
732
    private emit(eventName: 'project-reload', data: { project: LspProject });
733
    private emit(eventName: 'diagnostics', data: { project: LspProject; diagnostics: LspDiagnostic[] });
734
    private async emit(eventName: string, data?) {
735
        //emit these events on next tick, otherwise they will be processed immediately which could cause issues
736
        await util.sleep(0);
217✔
737
        this.emitter.emit(eventName, data);
217✔
738
    }
739
    private emitter = new EventEmitter();
69✔
740

741
    public dispose() {
742
        this.emitter.removeAllListeners();
69✔
743
        for (const project of this.projects) {
69✔
744
            project?.dispose?.();
57!
745
        }
746
    }
747
}
748

749
export interface WorkspaceConfig {
750
    /**
751
     * Absolute path to the folder where the workspace resides
752
     */
753
    workspaceFolder: string;
754
    /**
755
     * A list of glob patterns used to _exclude_ files from various bsconfig searches
756
     */
757
    excludePatterns?: string[];
758
    /**
759
     * 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
760
     */
761
    bsconfigPath?: string;
762
    /**
763
     * Should the projects in this workspace be run in their own dedicated worker threads, or all run on the main thread
764
     */
765
    enableThreading?: boolean;
766
}
767

768
interface StandaloneProject extends LspProject {
769
    /**
770
     * The path to the file that this project represents
771
     */
772
    srcPath: string;
773
}
774

775
/**
776
 * An annotation used to wrap the method in a busyStatus tracking call
777
 */
778
function TrackBusyStatus(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
779
    let originalMethod = descriptor.value;
14✔
780

781
    //wrapping the original method
782
    descriptor.value = function value(this: ProjectManager, ...args: any[]) {
14✔
783
        return this.busyStatusTracker.run(() => {
218✔
784
            return originalMethod.apply(this, args);
218✔
785
        }, originalMethod.name);
786
    };
787
}
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