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

rokucommunity / brighterscript / #13062

23 Sep 2024 02:51PM UTC coverage: 88.769% (+0.8%) from 87.933%
#13062

push

web-flow
Merge 373852d93 into 56dcaaa63

6620 of 7920 branches covered (83.59%)

Branch coverage included in aggregate %.

1025 of 1156 new or added lines in 28 files covered. (88.67%)

24 existing lines in 5 files now uncovered.

9456 of 10190 relevant lines covered (92.8%)

1711.5 hits per line

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

88.43
/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();
71!
31
        this.pathFilterer = options?.pathFilterer ?? new PathFilterer({ logger: options?.logger });
71!
32
        this.documentManager = new DocumentManager({
71✔
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) => {
71✔
40
            this.busyStatusTracker.beginScopedRun(event.project, `validate-project-${event.project.projectNumber}`);
81✔
41
        });
42
        this.on('validate-end', (event) => {
71✔
43
            void this.busyStatusTracker.endScopedRun(event.project, `validate-project-${event.project.projectNumber}`);
78✔
44
        });
45
    }
46

47
    private pathFilterer: PathFilterer;
48

49
    private logger: Logger;
50

51
    /**
52
     * Collection of all projects
53
     */
54
    public projects: LspProject[] = [];
71✔
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[] = [];
71✔
61

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

65
    public busyStatusTracker = new BusyStatusTracker();
71✔
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();
71✔
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) {
58!
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));
58✔
256

257
        this.syncPromise = (async () => {
58✔
258
            //build a list of unique projects across all workspace folders
259
            let projectConfigs = (await Promise.all(
58✔
260
                workspaceConfigs.map(async workspaceConfig => {
261
                    const projectPaths = await this.getProjectPaths(workspaceConfig);
57✔
262
                    return projectPaths.map(projectPath => ({
66✔
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);
58✔
273

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

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

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

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

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

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

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

314
    public handleFileChanges(changes: FileChange[]) {
315
        this.logger.log('handleFileChanges', changes.map(x => 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
            this.logger.log('handleFileChanges -> run', changes.map(x => x.srcPath));
69✔
320
            //wait for any pending syncs to finish
321
            await this.onInitialized();
60✔
322

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

719
        config.projectNumber = this.getProjectNumber(config);
67✔
720

721
        let project: LspProject = config.enableThreading
67✔
722
            ? new WorkerThreadProject({
67✔
723
                logger: this.logger.createLogger()
724
            })
725
            : new Project({
726
                logger: this.logger.createLogger()
727
            });
728

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

731
        this.projects.push(project);
67✔
732

733
        //pipe all project-specific events through our emitter, and include the project reference
734
        project.on('all', (eventName, data) => {
67✔
735
            this.emit(eventName as any, {
239✔
736
                ...data,
737
                project: project
738
            } as any);
739
        });
740
        return project;
67✔
741
    }
742

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

758
    @TrackBusyStatus
759
    private async activateProject(project: LspProject, config: ProjectConfig) {
1✔
760
        await project.activate(config);
67✔
761

762
        //send an event to indicate that this project has been activated
763
        this.emit('project-activate', { project: project });
66✔
764

765
        //register this project's list of files with the path filterer
766
        const unregister = this.pathFilterer.registerIncludeList(project.rootDir, project.filePatterns);
66✔
767
        project.disposables.push({ dispose: unregister });
66✔
768
    }
769

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

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

794
    public dispose() {
795
        this.emitter.removeAllListeners();
71✔
796
        for (const project of this.projects) {
71✔
797
            project?.dispose?.();
60!
798
        }
799
    }
800
}
801

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

821
interface StandaloneProject extends LspProject {
822
    /**
823
     * The path to the file that this project represents
824
     */
825
    srcPath: string;
826
}
827

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

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