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

rokucommunity / brighterscript / #13265

01 Nov 2024 05:58PM UTC coverage: 89.076% (+0.9%) from 88.214%
#13265

push

web-flow
Merge 30be955de into 7cfaaa047

7248 of 8577 branches covered (84.51%)

Branch coverage included in aggregate %.

1095 of 1214 new or added lines in 28 files covered. (90.2%)

24 existing lines in 5 files now uncovered.

9640 of 10382 relevant lines covered (92.85%)

1782.98 hits per line

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

87.96
/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
const FileChangeTypeLookup = Object.entries(FileChangeType).reduce((acc, [key, value]) => {
1✔
23
    acc[value] = key;
3✔
24
    acc[key] = value;
3✔
25
    return acc;
3✔
26
}, {});
27

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

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

53
    private pathFilterer: PathFilterer;
54

55
    private logger: Logger;
56

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

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

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

71
    public busyStatusTracker = new BusyStatusTracker<LspProject>();
85✔
72

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

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

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

89
        const actions = [...event.actions] as DocumentActionWithStatus[];
40✔
90

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

326
        //return the sync promise
327
        return this.syncPromise;
61✔
328
    }
329

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

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

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

342
            return this._handleFileChanges(changes);
67✔
343
        }, changes);
344
    }
345

346
    /**
347
     * Handle when files or directories are added, changed, or deleted in the workspace.
348
     * This is safe to call any time. Changes will be queued and flushed at the correct times
349
     */
350
    public async _handleFileChanges(changes: FileChange[]) {
351
        //filter any changes that are not allowed by the path filterer
352
        changes = this.pathFilterer.filter(changes, x => x.srcPath);
67✔
353

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

356
        //process all file changes in parallel
357
        await Promise.all(changes.map(async (change) => {
67✔
358
            await this.handleFileChange(change);
75✔
359
        }));
360
    }
361

362
    /**
363
     * Handle a single file change. If the file is a directory, this will recursively read all files in the directory and call `handleFileChanges` again
364
     */
365
    private async handleFileChange(change: FileChange) {
366
        const srcPath = util.standardizePath(change.srcPath);
79✔
367
        if (change.type === FileChangeType.Deleted) {
79✔
368
            //mark this document or directory as deleted
369
            this.documentManager.delete(srcPath);
1✔
370

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

389
                //this is a new file. set the file contents
390
            } else {
391
                this.documentManager.set({
76✔
392
                    srcPath: change.srcPath,
393
                    fileContents: change.fileContents,
394
                    allowStandaloneProject: change.allowStandaloneProject
395
                });
396
            }
397
        }
398

399
        //reload any projects whose bsconfig.json was changed
400
        const projectsToReload = this.projects.filter(x => x.bsconfigPath?.toLowerCase() === change.srcPath.toLowerCase());
93✔
401
        if (projectsToReload.length > 0) {
79✔
402
            await Promise.all(
2✔
403
                projectsToReload.map(x => this.reloadProject(x))
2✔
404
            );
405
        }
406
    }
407

408
    /**
409
     * Handle when a file is closed in the editor (this mostly just handles removing standalone projects)
410
     */
411
    public async handleFileClose(event: { srcPath: string }) {
412
        this.logger.debug(`File was closed. ${event.srcPath}`);
1✔
413
        this.removeStandaloneProject(event.srcPath);
1✔
414
        //most other methods on this class are async, might as well make this one async too for consistency and future expansion
415
        await Promise.resolve();
1✔
416
    }
417

418
    /**
419
     * Given a project, forcibly reload it by removing it and re-adding it
420
     */
421
    private async reloadProject(project: LspProject) {
422
        this.logger.log('Reloading project', { projectPath: project.projectPath });
2✔
423

424
        this.removeProject(project);
2✔
425
        project = await this.createAndActivateProject(project.activateOptions);
2✔
426
    }
427

428
    /**
429
     * Get all the semantic tokens for the given file
430
     * @returns an array of semantic tokens
431
     */
432
    @TrackBusyStatus
433
    public async getSemanticTokens(options: { srcPath: string }) {
1✔
434
        //wait for all pending syncs to finish
435
        await this.onIdle();
1✔
436

437
        let result = await util.promiseRaceMatch(
1✔
438
            this.projects.map(x => x.getSemanticTokens(options)),
1✔
439
            //keep the first non-falsey result
440
            (result) => result?.length > 0
1!
441
        );
442
        return result;
1✔
443
    }
444

445
    /**
446
     * Get a string containing the transpiled contents of the file at the given path
447
     * @returns the transpiled contents of the file as a string
448
     */
449
    @TrackBusyStatus
450
    public async transpileFile(options: { srcPath: string }) {
1✔
451
        //wait for all pending syncs to finish
452
        await this.onIdle();
2✔
453

454
        let result = await util.promiseRaceMatch(
2✔
455
            this.projects.map(x => x.transpileFile(options)),
2✔
456
            //keep the first non-falsey result
457
            (result) => !!result
2✔
458
        );
459
        return result;
2✔
460
    }
461

462
    /**
463
     *  Get the completions for the given position in the file
464
     */
465
    @TrackBusyStatus
466
    public async getCompletions(options: { srcPath: string; position: Position; cancellationToken?: CancellationToken }): Promise<CompletionList> {
1✔
467
        await this.onIdle();
1✔
468

469
        //if the request has been cancelled since originally requested due to idle time being slow, skip the rest of the wor
470
        if (options?.cancellationToken?.isCancellationRequested) {
1!
NEW
471
            this.logger.log('ProjectManager getCompletions cancelled', options);
×
NEW
472
            return;
×
473
        }
474

475
        this.logger.log('ProjectManager getCompletions', options);
1✔
476
        //Ask every project for results, keep whichever one responds first that has a valid response
477
        let result = await util.promiseRaceMatch(
1✔
478
            this.projects.map(x => x.getCompletions(options)),
1✔
479
            //keep the first non-falsey result
480
            (result) => result?.items?.length > 0
1!
481
        );
482
        return result;
1✔
483
    }
484

485
    /**
486
     * Get the hover information for the given position in the file. If multiple projects have hover information, the projects will be raced and
487
     * the fastest result will be returned
488
     * @returns the hover information or undefined if no hover information was found
489
     */
490
    @TrackBusyStatus
491
    public async getHover(options: { srcPath: string; position: Position }): Promise<Hover> {
1✔
492
        //wait for all pending syncs to finish
NEW
493
        await this.onIdle();
×
494

495
        //Ask every project for hover info, keep whichever one responds first that has a valid response
NEW
496
        let hover = await util.promiseRaceMatch(
×
NEW
497
            this.projects.map(x => x.getHover(options)),
×
498
            //keep the first set of non-empty results
NEW
499
            (result) => result?.length > 0
×
500
        );
NEW
501
        return hover?.[0];
×
502
    }
503

504
    /**
505
     * Get the definition for the symbol at the given position in the file
506
     * @returns a list of locations where the symbol under the position is defined in the project
507
     */
508
    @TrackBusyStatus
509
    public async getDefinition(options: { srcPath: string; position: Position }): Promise<Location[]> {
1✔
510
        //wait for all pending syncs to finish
511
        await this.onIdle();
5✔
512

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

515
        //Ask every project for definition info, keep whichever one responds first that has a valid response
516
        let result = await util.promiseRaceMatch(
5✔
517
            this.projects.map(x => x.getDefinition(options)),
5✔
518
            //keep the first non-falsey result
519
            (result) => !!result
5✔
520
        );
521
        return result;
5✔
522
    }
523

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

529
        //Ask every project for definition info, keep whichever one responds first that has a valid response
530
        let signatures = await util.promiseRaceMatch(
4✔
531
            this.projects.map(x => x.getSignatureHelp(options)),
4✔
532
            //keep the first non-falsey result
533
            (result) => !!result
4✔
534
        );
535

536
        if (signatures?.length > 0) {
4!
537
            const activeSignature = signatures.length > 0 ? 0 : undefined;
3!
538

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

541
            let result: SignatureHelp = {
3✔
542
                signatures: signatures.map((s) => s.signature),
3✔
543
                activeSignature: activeSignature,
544
                activeParameter: activeParameter
545
            };
546
            return result;
3✔
547
        }
548
    }
549

550
    @TrackBusyStatus
551
    public async getDocumentSymbol(options: { srcPath: string }): Promise<DocumentSymbol[]> {
1✔
552
        //wait for all pending syncs to finish
553
        await this.onIdle();
6✔
554

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

564
    @TrackBusyStatus
565
    public async getWorkspaceSymbol(): Promise<WorkspaceSymbol[]> {
1✔
566
        //wait for all pending syncs to finish
567
        await this.onIdle();
4✔
568

569
        //Ask every project for definition info, keep whichever one responds first that has a valid response
570
        let responses = await Promise.allSettled(
4✔
571
            this.projects.map(x => x.getWorkspaceSymbol())
4✔
572
        );
573
        let results = responses
4✔
574
            //keep all symbol results
575
            .map((x) => {
576
                return x.status === 'fulfilled' ? x.value : [];
4!
577
            })
578
            //flatten the array
579
            .flat()
580
            //throw out nulls
581
            .filter(x => !!x);
24✔
582

583
        // Remove duplicates
584
        const allSymbols = Object.values(
4✔
585
            results.reduce((map, symbol) => {
586
                const key = symbol.location.uri + symbol.name;
24✔
587
                map[key] = symbol;
24✔
588
                return map;
24✔
589
            }, {})
590
        );
591

592
        return allSymbols as SymbolInformation[];
4✔
593
    }
594

595
    @TrackBusyStatus
596
    public async getReferences(options: { srcPath: string; position: Position }): Promise<Location[]> {
1✔
597
        //wait for all pending syncs to finish
598
        await this.onIdle();
3✔
599

600
        //Ask every project for definition info, keep whichever one responds first that has a valid response
601
        let result = await util.promiseRaceMatch(
3✔
602
            this.projects.map(x => x.getReferences(options)),
3✔
603
            //keep the first non-falsey result
604
            (result) => !!result
3✔
605
        );
606
        return result ?? [];
3!
607
    }
608

609
    @TrackBusyStatus
610
    public async getCodeActions(options: { srcPath: string; range: Range }) {
1✔
611
        //wait for all pending syncs to finish
NEW
612
        await this.onIdle();
×
613

614
        //Ask every project for definition info, keep whichever one responds first that has a valid response
NEW
615
        let result = await util.promiseRaceMatch(
×
NEW
616
            this.projects.map(x => x.getCodeActions(options)),
×
617
            //keep the first non-falsey result
NEW
618
            (result) => !!result
×
619
        );
NEW
620
        return result;
×
621
    }
622

623
    /**
624
     * Scan a given workspace for all `bsconfig.json` files. If at least one is found, then only folders who have bsconfig.json are returned.
625
     * If none are found, then the workspaceFolder itself is treated as a project
626
     */
627
    private async getProjectPaths(workspaceConfig: WorkspaceConfig) {
628
        //get the list of exclude patterns, and negate them (so they actually work like excludes)
629
        const excludePatterns = (workspaceConfig.excludePatterns ?? []).map(x => s`!${x}`);
60✔
630
        let files = await rokuDeploy.getFilePaths([
60✔
631
            '**/bsconfig.json',
632
            //exclude all files found in `files.exclude`
633
            ...excludePatterns
634
        ], workspaceConfig.workspaceFolder);
635

636
        //filter the files to only include those that are allowed by the path filterer
637
        files = this.pathFilterer.filter(files, x => x.src);
60✔
638

639
        //if we found at least one bsconfig.json, then ALL projects must have a bsconfig.json.
640
        if (files.length > 0) {
60✔
641
            return files.map(file => s`${path.dirname(file.src)}`);
30✔
642
        }
643

644
        //look for roku project folders
645
        let rokuLikeDirs = (await Promise.all(
38✔
646
            //find all folders containing a `manifest` file
647
            (await rokuDeploy.getFilePaths([
648
                '**/manifest',
649
                ...excludePatterns
650

651
                //is there at least one .bs|.brs file under the `/source` folder?
652
            ], workspaceConfig.workspaceFolder)).map(async manifestEntry => {
653
                const manifestDir = path.dirname(manifestEntry.src);
5✔
654
                const files = await rokuDeploy.getFilePaths([
5✔
655
                    'source/**/*.{brs,bs}',
656
                    ...excludePatterns
657
                ], manifestDir);
658
                if (files.length > 0) {
5✔
659
                    return manifestDir;
3✔
660
                }
661
            })
662
            //throw out nulls
663
        )).filter(x => !!x);
5✔
664

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

668
        if (rokuLikeDirs.length > 0) {
38✔
669
            return rokuLikeDirs;
2✔
670
        }
671

672
        //treat the workspace folder as a brightscript project itself
673
        return [workspaceConfig.workspaceFolder];
36✔
674
    }
675

676
    /**
677
     * Returns true if we have this project, or false if we don't
678
     * @param projectPath path to the project
679
     * @returns true if the project exists, or false if it doesn't
680
     */
681
    private hasProject(projectPath: string) {
682
        return !!this.getProject(projectPath);
209✔
683
    }
684

685
    /**
686
     * Get a project with the specified path
687
     * @param param path to the project or an obj that has `projectPath` prop
688
     * @returns a project, or undefined if no project was found
689
     */
690
    private getProject(param: string | { projectPath: string }) {
691
        const projectPath = util.standardizePath(
211✔
692
            (typeof param === 'string') ? param : param.projectPath
211✔
693
        );
694
        return this.projects.find(x => x.projectPath === projectPath);
211✔
695
    }
696

697
    /**
698
     * Remove a project from the language server
699
     */
700
    private removeProject(project: LspProject) {
701
        const idx = this.projects.findIndex(x => x.projectPath === project?.projectPath);
12✔
702
        if (idx > -1) {
12✔
703
            this.logger.log('Removing project', { projectPath: project.projectPath, projectNumber: project.projectNumber });
9✔
704
            this.projects.splice(idx, 1);
9✔
705
        }
706
        //anytime we remove a project, we should emit an event that clears all of its diagnostics
707
        this.emit('diagnostics', { project: project, diagnostics: [] });
12✔
708
        project?.dispose();
12✔
709
        this.busyStatusTracker.endAllRunsForScope(project);
12✔
710
    }
711

712
    /**
713
     * A unique project counter to help distinguish log entries in lsp mode
714
     */
715
    private static projectNumberSequence = 0;
1✔
716

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

719
    /**
720
     * Get a projectNumber for a given config. Try to reuse project numbers when we've seen this project before
721
     *  - If the config already has one, use that.
722
     *  - If we've already seen this config before, use the same project number as before
723
     */
724
    private getProjectNumber(config: ProjectConfig) {
725
        if (config.projectNumber !== undefined) {
72✔
726
            return config.projectNumber;
8✔
727
        }
728
        return ProjectManager.projectNumberCache.getOrAdd(`${s(config.projectPath)}-${s(config.workspaceFolder)}-${config.bsconfigPath}`, () => {
64✔
729
            return ProjectManager.projectNumberSequence++;
11✔
730
        });
731
    }
732

733
    /**
734
     * Constructs a project for the given config. Just makes the project, doesn't activate it
735
     * @returns a new project, or the existing project if one already exists with this config info
736
     */
737
    private constructProject(config: ProjectConfig): LspProject {
738
        //skip this project if we already have it
739
        if (this.hasProject(config.projectPath)) {
72!
NEW
740
            return this.getProject(config.projectPath);
×
741
        }
742

743
        config.projectNumber = this.getProjectNumber(config);
72✔
744
        const projectIdentifier = `prj${config.projectNumber}`;
72✔
745

746
        let project: LspProject = config.enableThreading
72✔
747
            ? new WorkerThreadProject({
72✔
748
                logger: this.logger.createLogger(),
749
                projectIdentifier: projectIdentifier
750
            })
751
            : new Project({
752
                logger: this.logger.createLogger(),
753
                projectIdentifier: projectIdentifier
754
            });
755

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

758
        this.projects.push(project);
72✔
759

760
        //pipe all project-specific events through our emitter, and include the project reference
761
        project.on('all', (eventName, data) => {
72✔
762
            this.emit(eventName as any, {
260✔
763
                ...data,
764
                project: project
765
            } as any);
766
        });
767
        return project;
72✔
768
    }
769

770
    /**
771
     * Constructs a project for the given config
772
     * @returns a new project, or the existing project if one already exists with this config info
773
     */
774
    @TrackBusyStatus
775
    private async createAndActivateProject(config: ProjectConfig): Promise<LspProject> {
1✔
776
        //skip this project if we already have it
777
        if (this.hasProject(config.projectPath)) {
68✔
778
            return this.getProject(config.projectPath);
1✔
779
        }
780
        const project = this.constructProject(config);
67✔
781
        await this.activateProject(project, config);
67✔
782
        return project;
66✔
783
    }
784

785
    @TrackBusyStatus
786
    private async activateProject(project: LspProject, config: ProjectConfig) {
1✔
787
        this.logger.debug('Activating project', project.projectIdentifier, {
72✔
788
            projectPath: config?.projectPath,
216!
789
            bsconfigPath: config.bsconfigPath
790
        });
791
        await project.activate(config);
72✔
792

793
        //send an event to indicate that this project has been activated
794
        this.emit('project-activate', { project: project });
71✔
795

796
        //register this project's list of files with the path filterer
797
        const unregister = this.pathFilterer.registerIncludeList(project.rootDir, project.filePatterns);
71✔
798
        project.disposables.push({ dispose: unregister });
71✔
799
    }
800

801
    public on(eventName: 'validate-begin', handler: (data: { project: LspProject }) => MaybePromise<void>);
802
    public on(eventName: 'validate-end', handler: (data: { project: LspProject }) => MaybePromise<void>);
803
    public on(eventName: 'critical-failure', handler: (data: { project: LspProject; message: string }) => MaybePromise<void>);
804
    public on(eventName: 'project-activate', handler: (data: { project: LspProject }) => MaybePromise<void>);
805
    public on(eventName: 'diagnostics', handler: (data: { project: LspProject; diagnostics: LspDiagnostic[] }) => MaybePromise<void>);
806
    public on(eventName: string, handler: (payload: any) => MaybePromise<void>) {
807
        this.emitter.on(eventName, handler as any);
311✔
808
        return () => {
311✔
809
            this.emitter.removeListener(eventName, handler as any);
1✔
810
        };
811
    }
812

813
    private emit(eventName: 'validate-begin', data: { project: LspProject });
814
    private emit(eventName: 'validate-end', data: { project: LspProject });
815
    private emit(eventName: 'critical-failure', data: { project: LspProject; message: string });
816
    private emit(eventName: 'project-activate', data: { project: LspProject });
817
    private emit(eventName: 'diagnostics', data: { project: LspProject; diagnostics: LspDiagnostic[] });
818
    private async emit(eventName: string, data?) {
819
        //emit these events on next tick, otherwise they will be processed immediately which could cause issues
820
        await util.sleep(0);
348✔
821
        this.emitter.emit(eventName, data);
348✔
822
    }
823
    private emitter = new EventEmitter();
85✔
824

825
    public dispose() {
826
        this.emitter.removeAllListeners();
85✔
827
        for (const project of this.projects) {
85✔
828
            project?.dispose?.();
64!
829
        }
830
    }
831
}
832

833
export interface WorkspaceConfig {
834
    /**
835
     * Absolute path to the folder where the workspace resides
836
     */
837
    workspaceFolder: string;
838
    /**
839
     * A list of glob patterns used to _exclude_ files from various bsconfig searches
840
     */
841
    excludePatterns?: string[];
842
    /**
843
     * 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
844
     */
845
    bsconfigPath?: string;
846
    /**
847
     * Should the projects in this workspace be run in their own dedicated worker threads, or all run on the main thread
848
     */
849
    enableThreading?: boolean;
850
}
851

852
interface StandaloneProject extends LspProject {
853
    /**
854
     * The path to the file that this project represents
855
     */
856
    srcPath: string;
857
}
858

859
/**
860
 * An annotation used to wrap the method in a busyStatus tracking call
861
 */
862
function TrackBusyStatus(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
863
    let originalMethod = descriptor.value;
14✔
864

865
    //wrapping the original method
866
    descriptor.value = function value(this: ProjectManager, ...args: any[]) {
14✔
867
        return this.busyStatusTracker.run(() => {
267✔
868
            return originalMethod.apply(this, args);
267✔
869
        }, originalMethod.name);
870
    };
871
}
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