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

rokucommunity / brighterscript / #13841

05 Apr 2024 01:47PM UTC coverage: 89.047% (+1.6%) from 87.41%
#13841

push

TwitchBronBron
Fix completions crash

6410 of 7642 branches covered (83.88%)

Branch coverage included in aggregate %.

1 of 1 new or added line in 1 file covered. (100.0%)

139 existing lines in 7 files now uncovered.

9281 of 9979 relevant lines covered (93.01%)

1693.04 hits per line

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

85.47
/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 { Logger } from '../Logger';
1✔
18

19
/**
20
 * Manages all brighterscript projects for the language server
21
 */
22
export class ProjectManager {
1✔
23
    constructor(options?: {
24
        pathFilterer: PathFilterer;
25
        logger?: Logger;
26
    }) {
27
        this.pathFilterer = options?.pathFilterer ?? new PathFilterer();
55!
28
        this.logger = options?.logger ?? new Logger();
55!
29
        this.documentManager.on('flush', (event) => {
55✔
30
            void this.flushDocumentChanges(event).catch(e => console.error(e));
13✔
31
        });
32

33
        this.on('validate-begin', (event) => {
55✔
34
            this.busyStatusTracker.beginScopedRun(event.project, `validate-project-${event.project.projectNumber}`);
66✔
35
        });
36
        this.on('validate-end', (event) => {
55✔
37
            void this.busyStatusTracker.endScopedRun(event.project, `validate-project-${event.project.projectNumber}`);
63✔
38
        });
39
    }
40

41
    private pathFilterer: PathFilterer;
42

43
    private logger: Logger;
44

45
    /**
46
     * Collection of all projects
47
     */
48
    public projects: LspProject[] = [];
55✔
49

50
    /**
51
     * Collection of standalone projects. These are projects that are not part of a workspace, but are instead single files.
52
     * All of these are also present in the `projects` collection.
53
     */
54
    private standaloneProjects: StandaloneProject[] = [];
55✔
55

56
    private documentManager = new DocumentManager({
55✔
57
        delay: ProjectManager.documentManagerDelay
58
    });
59
    public static documentManagerDelay = 150;
1✔
60

61
    public busyStatusTracker = new BusyStatusTracker();
55✔
62

63
    /**
64
     * Apply all of the queued document changes. This should only be called as a result of the documentManager flushing changes, and never called manually
65
     * @param event the document changes that have occurred since the last time we applied
66
     */
67
    @TrackBusyStatus
68
    @OnReady
69
    private async flushDocumentChanges(event: FlushEvent) {
1✔
70
        const actions = [...event.actions] as DocumentActionWithStatus[];
13✔
71

72
        let idSequence = 0;
13✔
73
        //add an ID to every action (so we can track which actions were handled by which projects)
74
        for (const action of actions) {
13✔
75
            action.id = idSequence++;
25✔
76
        }
77

78
        //apply all of the document actions to each project in parallel
79
        const responses = await Promise.all(this.projects.map(async (project) => {
13✔
80
            //wait for this project to finish activating
81
            await project.whenActivated();
13✔
82

83
            const filterer = new PathCollection({
13✔
84
                rootDir: project.rootDir,
85
                globs: project.filePatterns
86
            });
87
            // only include files that are applicable to this specific project (still allow deletes to flow through since they're cheap)
88
            const projectActions = actions.filter(action => {
13✔
89
                return action.type === 'delete' || filterer.isMatch(action.srcPath);
28✔
90
            });
91
            return project.applyFileChanges(projectActions);
13✔
92
        }));
93

94
        //create standalone projects for any files not handled by any project
95
        const flatResponses = responses.flat();
13✔
96
        for (const action of actions) {
13✔
97
            //skip this action if it doesn't support standalone projects
98
            if (!action.allowStandaloneProject || action.type === 'delete') {
25✔
99
                continue;
23✔
100
            }
101

102
            // create a standalone project if this action was handled by zero projects and was a 'set' operation
103
            const wasHandled = flatResponses.some(x => x.id === action.id && action.type === 'set');
2✔
104
            if (wasHandled === false) {
2✔
105
                await this.createStandaloneProject(action.srcPath);
1✔
106
            }
107
        }
108
    }
109

110
    /**
111
     * Create a project that validates a single file. This is useful for getting language support for files that don't belong to a project
112
     */
113
    private async createStandaloneProject(srcPath: string) {
114
        srcPath = util.standardizePath(srcPath);
1✔
115
        const projectNumber = ProjectManager.projectNumberSequence++;
1✔
116
        const rootDir = path.join(__dirname, `standalone-project-${projectNumber}`);
1✔
117
        const projectOptions = {
1✔
118
            //these folders don't matter for standalone projects
119
            workspaceFolder: rootDir,
120
            projectPath: rootDir,
121
            enableThreading: false,
122
            projectNumber: projectNumber,
123
            files: [{
124
                src: srcPath,
125
                dest: 'source/standalone.brs'
126
            }]
127
        };
128
        const project = this.constructProject(projectOptions) as StandaloneProject;
1✔
129
        project.srcPath = srcPath;
1✔
130
        this.standaloneProjects.push(project);
1✔
131
        await this.activateProject(project, projectOptions);
1✔
132
    }
133

134
    private removeStandaloneProject(srcPath: string) {
135
        srcPath = util.standardizePath(srcPath);
1✔
136
        //remove all standalone projects that have this srcPath
137
        for (let i = this.standaloneProjects.length - 1; i >= 0; i--) {
1✔
138
            const project = this.standaloneProjects[i];
1✔
139
            if (project.srcPath === srcPath) {
1!
140
                this.removeProject(project);
1✔
141
                this.standaloneProjects.splice(i, 1);
1✔
142
            }
143
        }
144
    }
145

146
    /**
147
     * A promise that's set when a sync starts, and resolved when the sync is complete
148
     */
149
    private syncPromise: Promise<void> | undefined;
150
    private firstSync = new Deferred();
55✔
151

152
    /**
153
     * Get a promise that resolves when this manager is finished initializing
154
     */
155
    public onReady() {
156
        return Promise.allSettled([
51✔
157
            //wait for the first sync to finish
158
            this.firstSync.promise,
159
            //make sure we're not in the middle of a sync
160
            this.syncPromise,
161
            //make sure all pending file changes have been flushed
162
            this.documentManager.onSettle(),
163
            //make sure all projects are activated
164
            ...this.projects.map(x => x.whenActivated())
51✔
165
        ]);
166
    }
167
    /**
168
     * Given a list of all desired projects, create any missing projects and destroy and projects that are no longer available
169
     * Treat workspaces that don't have a bsconfig.json as a project.
170
     * Handle situations where bsconfig.json files were added or removed (to elevate/lower workspaceFolder projects accordingly)
171
     * Leave existing projects alone if they are not affected by these changes
172
     * @param workspaceConfigs an array of workspaces
173
     */
174
    @TrackBusyStatus
175
    public async syncProjects(workspaceConfigs: WorkspaceConfig[], forceReload = false) {
1✔
176
        //if we're force reloading, destroy all projects and start fresh
177
        if (forceReload) {
53!
UNCOV
178
            for (const project of this.projects) {
×
UNCOV
179
                this.removeProject(project);
×
180
            }
181
        }
182

183
        this.syncPromise = (async () => {
53✔
184
            //build a list of unique projects across all workspace folders
185
            let projectConfigs = (await Promise.all(
53✔
186
                workspaceConfigs.map(async workspaceConfig => {
187
                    const projectPaths = await this.getProjectPaths(workspaceConfig);
52✔
188
                    return projectPaths.map(projectPath => ({
61✔
189
                        projectPath: s`${projectPath}`,
190
                        workspaceFolder: s`${workspaceConfig.workspaceFolder}`,
191
                        excludePatterns: workspaceConfig.excludePatterns,
192
                        enableThreading: workspaceConfig.enableThreading
193
                    }));
194
                })
195
            )).flat(1);
196

197
            //filter the project paths to only include those that are allowed by the path filterer
198
            projectConfigs = this.pathFilterer.filter(projectConfigs, x => x.projectPath);
53✔
199

200
            //delete projects not represented in the list
201
            for (const project of this.projects) {
53✔
202
                //we can't find this existing project in our new list, so scrap it
203
                if (!projectConfigs.find(x => x.projectPath === project.projectPath)) {
10✔
204
                    this.removeProject(project);
4✔
205
                }
206
            }
207

208
            // skip projects we already have (they're already loaded...no need to reload them)
209
            projectConfigs = projectConfigs.filter(x => {
53✔
210
                return !this.hasProject(x.projectPath);
61✔
211
            });
212

213
            //dedupe by project path
214
            projectConfigs = [
53✔
215
                ...projectConfigs.reduce(
216
                    (acc, x) => acc.set(x.projectPath, x),
56✔
217
                    new Map<string, typeof projectConfigs[0]>()
218
                ).values()
219
            ];
220

221
            //create missing projects
222
            await Promise.all(
53✔
223
                projectConfigs.map(async (config) => {
224
                    await this.createAndActivateProject(config);
55✔
225
                })
226
            );
227

228
            //mark that we've completed our first sync
229
            this.firstSync.tryResolve();
53✔
230
        })();
231

232
        //return the sync promise
233
        return this.syncPromise;
53✔
234
    }
235

236
    /**
237
     * Promise that resolves when all file changes have been processed (so we can queue file changes in sequence)
238
     */
239
    private handleFileChangesPromise: Promise<any> = Promise.resolve();
55✔
240

241
    public async handleFileChanges(changes: FileChange[]) {
242
        //wait for the previous file change handling to finish, then handle these changes
243
        this.handleFileChangesPromise = this.handleFileChangesPromise.catch((e) => {
13✔
UNCOV
244
            console.error(e);
×
245
            //ignore errors, they will be handled by the previous caller
246
        }).then(() => {
247
            //wait for the initial sync to finish
248
            return this._handleFileChanges(changes);
13✔
249
        });
250
        return this.handleFileChangesPromise;
13✔
251
    }
252

253
    /**
254
     * Handle when files or directories are added, changed, or deleted in the workspace.
255
     * This is safe to call any time. Changes will be queued and flushed at the correct times
256
     */
257
    public async _handleFileChanges(changes: FileChange[]) {
258
        //wait for any pending syncs to finish
259
        await this.onReady();
13✔
260

261
        //filter any changes that are not allowed by the path filterer
262
        changes = this.pathFilterer.filter(changes, x => x.srcPath);
13✔
263

264
        //process all file changes in parallel
265
        await Promise.all(changes.map(async (change) => {
13✔
266
            await this.handleFileChange(change);
21✔
267
        }));
268
    }
269

270
    /**
271
     * Handle a single file change. If the file is a directory, this will recursively read all files in the directory and call `handleFileChanges` again
272
     */
273
    private async handleFileChange(change: FileChange) {
274
        const srcPath = util.standardizePath(change.srcPath);
27✔
275
        if (change.type === FileChangeType.Deleted) {
27✔
276
            //mark this document or directory as deleted
277
            this.documentManager.delete(srcPath);
1✔
278

279
            //file added or changed
280
        } else {
281
            //if this is a new directory, read all files recursively and register those as file changes too
282
            if (util.isDirectorySync(srcPath)) {
26✔
283
                const files = await fastGlob('**/*', {
2✔
284
                    cwd: change.srcPath,
285
                    onlyFiles: true,
286
                    absolute: true
287
                });
288
                //pipe all files found recursively in the new directory through this same function so they can be processed correctly
289
                await Promise.all(files.map((srcPath) => {
2✔
290
                    return this.handleFileChange({
6✔
291
                        srcPath: srcPath,
292
                        type: FileChangeType.Changed,
293
                        allowStandaloneProject: change.allowStandaloneProject
294
                    });
295
                }));
296

297
                //this is a new file. set the file contents
298
            } else {
299
                this.documentManager.set({
24✔
300
                    srcPath: change.srcPath,
301
                    fileContents: change.fileContents,
302
                    allowStandaloneProject: change.allowStandaloneProject
303
                });
304
            }
305
        }
306

307
        //reload any projects whose bsconfig.json was changed
308
        const projectsToReload = this.projects.filter(x => x.bsconfigPath?.toLowerCase() === change.srcPath.toLowerCase());
30✔
309
        await Promise.all(
27✔
310
            projectsToReload.map(x => this.reloadProject(x))
×
311
        );
312
    }
313

314
    /**
315
     * Handle when a file is closed in the editor (this mostly just handles removing standalone projects)
316
     */
317
    public async handleFileClose(event: { srcPath: string }) {
318
        this.removeStandaloneProject(event.srcPath);
1✔
319
        //most other methods on this class are async, might as well make this one async too for consistency and future expansion
320
        await Promise.resolve();
1✔
321
    }
322

323
    /**
324
     * Given a project, forcibly reload it by removing it and re-adding it
325
     */
326
    private async reloadProject(project: LspProject) {
UNCOV
327
        this.removeProject(project);
×
328
        project = await this.createAndActivateProject(project.activateOptions);
×
UNCOV
329
        this.emit('project-reload', { project: project });
×
330
    }
331

332
    /**
333
     * Get all the semantic tokens for the given file
334
     * @returns an array of semantic tokens
335
     */
336
    @TrackBusyStatus
337
    @OnReady
338
    public async getSemanticTokens(options: { srcPath: string }) {
1✔
339
        let result = await util.promiseRaceMatch(
1✔
340
            this.projects.map(x => x.getSemanticTokens(options)),
1✔
341
            //keep the first non-falsey result
342
            (result) => result?.length > 0
1!
343
        );
344
        return result;
1✔
345
    }
346

347
    /**
348
     * Get a string containing the transpiled contents of the file at the given path
349
     * @returns the transpiled contents of the file as a string
350
     */
351
    @TrackBusyStatus
352
    @OnReady
353
    public async transpileFile(options: { srcPath: string }) {
1✔
354
        let result = await util.promiseRaceMatch(
2✔
355
            this.projects.map(x => x.transpileFile(options)),
2✔
356
            //keep the first non-falsey result
357
            (result) => !!result
2✔
358
        );
359
        return result;
2✔
360
    }
361

362
    /**
363
     *  Get the completions for the given position in the file
364
     */
365
    @TrackBusyStatus
366
    @OnReady
367
    public async getCompletions(options: { srcPath: string; position: Position }): Promise<CompletionList> {
1✔
368
        //Ask every project for results, keep whichever one responds first that has a valid response
369
        let result = await util.promiseRaceMatch(
×
UNCOV
370
            this.projects.map(x => x.getCompletions(options)),
×
371
            //keep the first non-falsey result
372
            (result) => result?.items?.length > 0
×
373
        );
UNCOV
374
        return result;
×
375
    }
376

377
    /**
378
     * Get the hover information for the given position in the file. If multiple projects have hover information, the projects will be raced and
379
     * the fastest result will be returned
380
     * @returns the hover information or undefined if no hover information was found
381
     */
382
    @TrackBusyStatus
383
    @OnReady
384
    public async getHover(options: { srcPath: string; position: Position }): Promise<Hover> {
1✔
385
        //Ask every project for hover info, keep whichever one responds first that has a valid response
UNCOV
386
        let hover = await util.promiseRaceMatch(
×
387
            this.projects.map(x => x.getHover(options)),
×
388
            //keep the first set of non-empty results
389
            (result) => result?.length > 0
×
390
        );
UNCOV
391
        return hover?.[0];
×
392
    }
393

394
    /**
395
     * Get the definition for the symbol at the given position in the file
396
     * @returns a list of locations where the symbol under the position is defined in the project
397
     */
398
    @TrackBusyStatus
399
    @OnReady
400
    public async getDefinition(options: { srcPath: string; position: Position }): Promise<Location[]> {
1✔
401
        //TODO should we merge definitions across ALL projects? or just return definitions from the first project we found
402

403
        //Ask every project for definition info, keep whichever one responds first that has a valid response
404
        let result = await util.promiseRaceMatch(
5✔
405
            this.projects.map(x => x.getDefinition(options)),
5✔
406
            //keep the first non-falsey result
407
            (result) => !!result
5✔
408
        );
409
        return result;
5✔
410
    }
411

412
    @TrackBusyStatus
413
    @OnReady
414
    public async getSignatureHelp(options: { srcPath: string; position: Position }): Promise<SignatureHelp> {
1✔
415
        //Ask every project for definition info, keep whichever one responds first that has a valid response
416
        let signatures = await util.promiseRaceMatch(
4✔
417
            this.projects.map(x => x.getSignatureHelp(options)),
4✔
418
            //keep the first non-falsey result
419
            (result) => !!result
4✔
420
        );
421

422
        if (signatures?.length > 0) {
4✔
423
            const activeSignature = signatures.length > 0 ? 0 : undefined;
3!
424

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

427
            let result: SignatureHelp = {
3✔
428
                signatures: signatures.map((s) => s.signature),
3✔
429
                activeSignature: activeSignature,
430
                activeParameter: activeParameter
431
            };
432
            return result;
3✔
433
        }
434
    }
435

436
    @TrackBusyStatus
437
    @OnReady
438
    public async getDocumentSymbol(options: { srcPath: string }): Promise<DocumentSymbol[]> {
1✔
439
        //Ask every project for definition info, keep whichever one responds first that has a valid response
440
        let result = await util.promiseRaceMatch(
6✔
441
            this.projects.map(x => x.getDocumentSymbol(options)),
6✔
442
            //keep the first non-falsey result
443
            (result) => !!result
6✔
444
        );
445
        return result;
6✔
446
    }
447

448
    @TrackBusyStatus
449
    @OnReady
450
    public async getWorkspaceSymbol(): Promise<WorkspaceSymbol[]> {
1✔
451
        //Ask every project for definition info, keep whichever one responds first that has a valid response
452
        let responses = await Promise.allSettled(
4✔
453
            this.projects.map(x => x.getWorkspaceSymbol())
4✔
454
        );
455
        let results = responses
4✔
456
            //keep all symbol results
457
            .map((x) => {
458
                return x.status === 'fulfilled' ? x.value : [];
4!
459
            })
460
            //flatten the array
461
            .flat()
462
            //throw out nulls
463
            .filter(x => !!x);
24✔
464

465
        // Remove duplicates
466
        const allSymbols = Object.values(
4✔
467
            results.reduce((map, symbol) => {
468
                const key = symbol.location.uri + symbol.name;
24✔
469
                map[key] = symbol;
24✔
470
                return map;
24✔
471
            }, {})
472
        );
473

474
        return allSymbols as SymbolInformation[];
4✔
475
    }
476

477
    @TrackBusyStatus
478
    @OnReady
479
    public async getReferences(options: { srcPath: string; position: Position }): Promise<Location[]> {
1✔
480
        //Ask every project for definition info, keep whichever one responds first that has a valid response
481
        let result = await util.promiseRaceMatch(
3✔
482
            this.projects.map(x => x.getReferences(options)),
3✔
483
            //keep the first non-falsey result
484
            (result) => !!result
3✔
485
        );
486
        return result ?? [];
3!
487
    }
488

489
    @TrackBusyStatus
490
    @OnReady
491
    public async getCodeActions(options: { srcPath: string; range: Range }) {
1✔
492
        //Ask every project for definition info, keep whichever one responds first that has a valid response
UNCOV
493
        let result = await util.promiseRaceMatch(
×
UNCOV
494
            this.projects.map(x => x.getCodeActions(options)),
×
495
            //keep the first non-falsey result
UNCOV
496
            (result) => !!result
×
497
        );
UNCOV
498
        return result;
×
499
    }
500

501
    /**
502
     * Scan a given workspace for all `bsconfig.json` files. If at least one is found, then only folders who have bsconfig.json are returned.
503
     * If none are found, then the workspaceFolder itself is treated as a project
504
     */
505
    private async getProjectPaths(workspaceConfig: WorkspaceConfig) {
506
        //get the list of exclude patterns, and negate them (so they actually work like excludes)
507
        const excludePatterns = (workspaceConfig.excludePatterns ?? []).map(x => s`!${x}`);
52✔
508
        let files = await rokuDeploy.getFilePaths([
52✔
509
            '**/bsconfig.json',
510
            //exclude all files found in `files.exclude`
511
            ...excludePatterns
512
        ], workspaceConfig.workspaceFolder);
513

514
        //filter the files to only include those that are allowed by the path filterer
515
        files = this.pathFilterer.filter(files, x => x.src);
52✔
516

517
        //if we found at least one bsconfig.json, then ALL projects must have a bsconfig.json.
518
        if (files.length > 0) {
52✔
519
            return files.map(file => s`${path.dirname(file.src)}`);
25✔
520
        }
521

522
        //look for roku project folders
523
        let rokuLikeDirs = (await Promise.all(
35✔
524
            //find all folders containing a `manifest` file
525
            (await rokuDeploy.getFilePaths([
526
                '**/manifest',
527
                ...excludePatterns
528

529
                //is there at least one .bs|.brs file under the `/source` folder?
530
            ], workspaceConfig.workspaceFolder)).map(async manifestEntry => {
531
                const manifestDir = path.dirname(manifestEntry.src);
5✔
532
                const files = await rokuDeploy.getFilePaths([
5✔
533
                    'source/**/*.{brs,bs}',
534
                    ...excludePatterns
535
                ], manifestDir);
536
                if (files.length > 0) {
5✔
537
                    return manifestDir;
3✔
538
                }
539
            })
540
            //throw out nulls
541
        )).filter(x => !!x);
5✔
542

543
        //throw out any directories that are not allowed by the path filterer
544
        rokuLikeDirs = this.pathFilterer.filter(rokuLikeDirs, srcPath => srcPath);
35✔
545

546
        if (rokuLikeDirs.length > 0) {
35✔
547
            return rokuLikeDirs;
2✔
548
        }
549

550
        //treat the workspace folder as a brightscript project itself
551
        return [workspaceConfig.workspaceFolder];
33✔
552
    }
553

554
    /**
555
     * Returns true if we have this project, or false if we don't
556
     * @param projectPath path to the project
557
     * @returns true if the project exists, or false if it doesn't
558
     */
559
    private hasProject(projectPath: string) {
560
        return !!this.getProject(projectPath);
177✔
561
    }
562

563
    /**
564
     * Get a project with the specified path
565
     * @param param path to the project or an obj that has `projectPath` prop
566
     * @returns a project, or undefined if no project was found
567
     */
568
    private getProject(param: string | { projectPath: string }) {
569
        const projectPath = util.standardizePath(
179✔
570
            (typeof param === 'string') ? param : param.projectPath
179✔
571
        );
572
        return this.projects.find(x => x.projectPath === projectPath);
179✔
573
    }
574

575
    /**
576
     * Remove a project from the language server
577
     */
578
    private removeProject(project: LspProject) {
579
        const idx = this.projects.findIndex(x => x.projectPath === project?.projectPath);
8✔
580
        if (idx > -1) {
8✔
581
            this.projects.splice(idx, 1);
5✔
582
        }
583
        project?.dispose();
8✔
584
        this.busyStatusTracker.endAllRunsForScope(project);
8✔
585
    }
586

587
    /**
588
     * A unique project counter to help distinguish log entries in lsp mode
589
     */
590
    private static projectNumberSequence = 0;
1✔
591

592
    /**
593
     * Constructs a project for the given config. Just makes the project, doesn't activate it
594
     * @returns a new project, or the existing project if one already exists with this config info
595
     */
596
    private constructProject(config: ProjectConfig): LspProject {
597
        //skip this project if we already have it
598
        if (this.hasProject(config.projectPath)) {
58!
UNCOV
599
            return this.getProject(config.projectPath);
×
600
        }
601

602
        config.projectNumber ??= ProjectManager.projectNumberSequence++;
58✔
603

604
        let project: LspProject = config.enableThreading
58✔
605
            ? new WorkerThreadProject()
58✔
606
            : new Project();
607

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

610
        this.projects.push(project);
58✔
611

612
        //pipe all project-specific events through our emitter, and include the project reference
613
        project.on('all', (eventName, data) => {
58✔
614
            this.emit(eventName as any, {
194✔
615
                ...data,
616
                project: project
617
            } as any);
618
        });
619
        return project;
58✔
620
    }
621

622
    /**
623
     * Constructs a project for the given config
624
     * @returns a new project, or the existing project if one already exists with this config info
625
     */
626
    @TrackBusyStatus
627
    private async createAndActivateProject(config: ProjectConfig): Promise<LspProject> {
1✔
628
        //skip this project if we already have it
629
        if (this.hasProject(config.projectPath)) {
58✔
630
            return this.getProject(config.projectPath);
1✔
631
        }
632
        const project = this.constructProject(config);
57✔
633
        await this.activateProject(project, config);
57✔
634
        return project;
56✔
635
    }
636

637
    @TrackBusyStatus
638
    private async activateProject(project: LspProject, config: ProjectConfig) {
1✔
639
        await project.activate(config);
58✔
640

641
        //register this project's list of files with the path filterer
642
        const unregister = this.pathFilterer.registerIncludeList(project.rootDir, project.filePatterns);
57✔
643
        project.disposables.push({ dispose: unregister });
57✔
644
    }
645

646
    public on(eventName: 'validate-begin', handler: (data: { project: LspProject }) => MaybePromise<void>);
647
    public on(eventName: 'validate-end', handler: (data: { project: LspProject }) => MaybePromise<void>);
648
    public on(eventName: 'critical-failure', handler: (data: { project: LspProject; message: string }) => MaybePromise<void>);
649
    public on(eventName: 'project-reload', handler: (data: { project: LspProject }) => MaybePromise<void>);
650
    public on(eventName: 'diagnostics', handler: (data: { project: LspProject; diagnostics: LspDiagnostic[] }) => MaybePromise<void>);
651
    public on(eventName: string, handler: (payload: any) => MaybePromise<void>) {
652
        this.emitter.on(eventName, handler as any);
193✔
653
        return () => {
193✔
654
            this.emitter.removeListener(eventName, handler as any);
1✔
655
        };
656
    }
657

658
    private emit(eventName: 'validate-begin', data: { project: LspProject });
659
    private emit(eventName: 'validate-end', data: { project: LspProject });
660
    private emit(eventName: 'critical-failure', data: { project: LspProject; message: string });
661
    private emit(eventName: 'project-reload', data: { project: LspProject });
662
    private emit(eventName: 'diagnostics', data: { project: LspProject; diagnostics: LspDiagnostic[] });
663
    private async emit(eventName: string, data?) {
664
        //emit these events on next tick, otherwise they will be processed immediately which could cause issues
665
        await util.sleep(0);
197✔
666
        this.emitter.emit(eventName, data);
197✔
667
    }
668
    private emitter = new EventEmitter();
55✔
669

670
    public dispose() {
671
        this.emitter.removeAllListeners();
55✔
672
        for (const project of this.projects) {
55✔
673
            project?.dispose?.();
53!
674
        }
675
    }
676
}
677

678
export interface WorkspaceConfig {
679
    /**
680
     * Absolute path to the folder where the workspace resides
681
     */
682
    workspaceFolder: string;
683
    /**
684
     * A list of glob patterns used to _exclude_ files from various bsconfig searches
685
     */
686
    excludePatterns?: string[];
687
    /**
688
     * 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
689
     */
690
    bsconfigPath?: string;
691
    /**
692
     * Should the projects in this workspace be run in their own dedicated worker threads, or all run on the main thread
693
     */
694
    enableThreading?: boolean;
695
}
696

697
interface StandaloneProject extends LspProject {
698
    /**
699
     * The path to the file that this project represents
700
     */
701
    srcPath: string;
702
}
703

704
/**
705
 * An annotation used to wrap the method in a busyStatus tracking call
706
 */
707
function TrackBusyStatus(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
708
    let originalMethod = descriptor.value;
14✔
709

710
    //wrapping the original method
711
    descriptor.value = function value(this: ProjectManager, ...args: any[]) {
14✔
712
        return this.busyStatusTracker.run(() => {
207✔
713
            return originalMethod.apply(this, args);
207✔
714
        }, originalMethod.name);
715
    };
716
}
717

718
/**
719
 * Wraps the method in a an awaited call to `onReady` to ensure the project manager is ready before the method is called
720
 */
721
function OnReady(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
722
    let originalMethod = descriptor.value;
11✔
723

724
    //wrapping the original method
725
    descriptor.value = async function value(this: ProjectManager, ...args: any[]) {
11✔
726
        await this.onReady();
38✔
727
        return originalMethod.apply(this, args);
38✔
728
    };
729
}
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