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

rokucommunity / brighterscript / #15912

13 May 2026 06:48PM UTC coverage: 86.915%. Remained the same
#15912

push

web-flow
Merge 7fe8ea0ed into 9de11ed0c

15645 of 19004 branches covered (82.32%)

Branch coverage included in aggregate %.

16357 of 17816 relevant lines covered (91.81%)

27320.82 hits per line

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

87.37
/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 { FileRenameTextEdit, 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, SelectionRange } 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, LogLevel } from '../logging';
18
import { createLogger } from '../logging';
1✔
19
import { Cache } from '../Cache';
1✔
20
import { ActionQueue } from './ActionQueue';
1✔
21
import * as fsExtra from 'fs-extra';
1✔
22
import type { BrightScriptProjectConfiguration } from '../LanguageServer';
23

24
const FileChangeTypeLookup = Object.entries(FileChangeType).reduce((acc, [key, value]) => {
1✔
25
    acc[value] = key;
3✔
26
    acc[key] = value;
3✔
27
    return acc;
3✔
28
}, {});
29

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

47
        this.on('validate-begin', (event) => {
159✔
48
            this.busyStatusTracker.beginScopedRun(event.project, `validate-project`);
299✔
49
        });
50
        this.on('validate-end', (event) => {
159✔
51
            void this.busyStatusTracker.endScopedRun(event.project, `validate-project`);
296✔
52
        });
53
    }
54

55
    private pathFilterer: PathFilterer;
56

57
    private logger: Logger;
58

59
    /**
60
     * Collection of all projects
61
     */
62
    public projects: LspProject[] = [];
159✔
63

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

70
    private documentManager: DocumentManager;
71
    public static documentManagerDelay = 150;
1✔
72

73
    /**
74
     * Maximum number of projects to activate or validate concurrently during syncProjects.
75
     * Limits CPU spikes when many projects are discovered (e.g. in large monorepos).
76
     */
77
    public projectActivationConcurrencyLimit = 3;
159✔
78

79
    public busyStatusTracker = new BusyStatusTracker<LspProject>();
159✔
80

81
    /**
82
     * Cache for PathCollection instances per project. Avoids recreating PathCollection
83
     * on every document flush, which is wasteful since file patterns only change when a project is reloaded.
84
     */
85
    private projectFiltererCache = new WeakMap<LspProject, PathCollection>();
159✔
86

87
    /**
88
     * Get or create a cached PathCollection for the given project.
89
     * The filterer is invalidated when the project is removed and garbage collected.
90
     */
91
    private getProjectFilterer(project: LspProject): PathCollection {
92
        let filterer = this.projectFiltererCache.get(project);
52✔
93
        if (!filterer) {
52✔
94
            filterer = new PathCollection({
40✔
95
                rootDir: project.rootDir,
96
                globs: project.filePatterns
97
            });
98
            this.projectFiltererCache.set(project, filterer);
40✔
99
        }
100
        return filterer;
52✔
101
    }
102

103
    /**
104
     * Apply all of the queued document changes. This should only be called as a result of the documentManager flushing changes, and never called manually
105
     * @param event the document changes that have occurred since the last time we applied
106
     */
107
    @TrackBusyStatus
108
    private async flushDocumentChanges(event: FlushEvent) {
1✔
109

110
        this.logger.info(`flushDocumentChanges`, event?.actions?.map(x => ({
80!
111
            type: x.type,
112
            srcPath: x.srcPath,
113
            allowStandaloneProject: x.allowStandaloneProject
114
        })));
115

116
        //ensure that we're fully initialized before proceeding
117
        await this.onInitialized();
45✔
118

119
        const actions = [...event.actions] as DocumentActionWithStatus[];
45✔
120

121
        let idSequence = 0;
45✔
122
        //add an ID to every action (so we can track which actions were handled by which projects)
123
        for (const action of actions) {
45✔
124
            action.id = idSequence++;
80✔
125
        }
126

127
        //apply all of the document actions to each project in parallel
128
        const responses = await Promise.all(this.projects.map(async (project) => {
45✔
129
            //wait for this project to finish activating
130
            await project.whenActivated();
52✔
131

132
            const filterer = this.getProjectFilterer(project);
52✔
133
            // only include files that are applicable to this specific project (still allow deletes to flow through since they're cheap)
134
            const projectActions = actions.filter(action => {
52✔
135
                return (
92✔
136
                    //if this is a delete, just pass it through because they're cheap to apply
137
                    action.type === 'delete' ||
182✔
138
                    //if this is a set, only pass it through if it's a file that this project cares about
139
                    filterer.isMatch(action.srcPath)
140
                );
141
            });
142
            if (projectActions.length > 0) {
52✔
143
                const responseActions = await project.applyFileChanges(projectActions);
41✔
144
                return responseActions.map(x => ({
75✔
145
                    project: project,
146
                    action: x
147
                }));
148
            }
149
        }));
150

151
        //create standalone projects for any files not handled by any project
152
        const flatResponses = responses.flat();
45✔
153
        for (const action of actions) {
45✔
154
            //skip this action if it doesn't support standalone projects
155
            if (!action.allowStandaloneProject || action.type !== 'set') {
80✔
156
                continue;
29✔
157
            }
158

159
            //a list of responses that handled this action
160
            const handledResponses = flatResponses.filter(x => x?.action?.id === action.id && x?.action?.status === 'accepted');
144!
161

162
            //remove any standalone project created for this file since it was handled by a normal project
163
            const normalProjectsThatHandledThisFile = handledResponses.filter(x => !x.project.isStandaloneProject);
51✔
164
            if (normalProjectsThatHandledThisFile.length > 0) {
51✔
165
                //if there's a standalone project for this file, delete it
166
                if (this.getStandaloneProject(action.srcPath, false)) {
42✔
167
                    this.logger.debug(
1✔
168
                        `flushDocumentChanges: removing standalone project because the following normal projects handled the file: '${action.srcPath}', projects:`,
169
                        normalProjectsThatHandledThisFile.map(x => util.getProjectLogName(x.project))
1✔
170
                    );
171
                    this.removeStandaloneProject(action.srcPath);
1✔
172
                }
173

174
                // create a standalone project if this action was handled by zero normal projects.
175
                //(safe to call even if there's already a standalone project, won't create dupes)
176
            } else {
177
                //TODO only create standalone projects for files we understand (brightscript, brighterscript, scenegraph xml, etc)
178
                await this.createStandaloneProject(action.srcPath);
9✔
179
            }
180
        }
181
        this.logger.info('flushDocumentChanges complete', actions.map(x => ({
80✔
182
            type: x.type,
183
            srcPath: x.srcPath,
184
            allowStandaloneProject: x.allowStandaloneProject
185
        })));
186
    }
187

188
    /**
189
     * Get a standalone project for a given file path
190
     */
191
    private getStandaloneProject(srcPath: string, standardizePath = true) {
×
192
        return this.standaloneProjects.get(
53✔
193
            standardizePath ? util.standardizePath(srcPath) : srcPath
53!
194
        );
195
    }
196

197
    /**
198
     * Create a project that validates a single file. This is useful for getting language support for files that don't belong to a project
199
     */
200
    private async createStandaloneProject(srcPath: string) {
201
        srcPath = util.standardizePath(srcPath);
9✔
202

203
        //if we already have a standalone project with this path, do nothing because it already exists
204
        if (this.getStandaloneProject(srcPath, false)) {
9✔
205
            this.logger.log('createStandaloneProject skipping because we already have one for this path');
4✔
206
            return;
4✔
207
        }
208

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

211
        const projectNumber = ProjectManager.projectNumberSequence++;
5✔
212
        const rootDir = path.join(__dirname, `standalone-project-${projectNumber}`);
5✔
213
        const projectConfig: ProjectConfig = {
5✔
214
            //these folders don't matter for standalone projects
215
            workspaceFolder: rootDir,
216
            projectDir: rootDir,
217
            //there's no bsconfig.json for standalone projects, so projectKey is the same as the dir
218
            projectKey: rootDir,
219
            bsconfigPath: undefined,
220
            enableThreading: false,
221
            projectNumber: projectNumber,
222
            files: [{
223
                src: srcPath,
224
                dest: 'source/standalone.brs'
225
            }]
226
        };
227

228
        const project = this.constructProject(projectConfig) as StandaloneProject;
5✔
229
        project.srcPath = srcPath;
5✔
230
        project.isStandaloneProject = true;
5✔
231

232
        this.standaloneProjects.set(srcPath, project);
5✔
233
        await this.activateProject(project, projectConfig);
5✔
234
        void project.validate();
5✔
235
    }
236

237
    private removeStandaloneProject(srcPath: string) {
238
        srcPath = util.standardizePath(srcPath);
2✔
239
        const project = this.getStandaloneProject(srcPath, false);
2✔
240
        if (project) {
2!
241
            if (project.srcPath === srcPath) {
2!
242
                this.logger.debug(`Removing standalone project for file '${srcPath}'`);
2✔
243
                this.removeProject(project);
2✔
244
                this.standaloneProjects.delete(srcPath);
2✔
245
            }
246
        }
247
    }
248

249
    /**
250
     * A promise that's set when a sync starts, and resolved when the sync is complete
251
     */
252
    private syncPromise: Promise<void> | undefined;
253
    private firstSync = new Deferred();
159✔
254

255
    /**
256
     * Monotonically increasing counter used to detect stale sync cycles.
257
     * When a new `syncProjects` call arrives, any in-progress activation or validation
258
     * from a previous cycle will see a mismatched generation and bail out.
259
     */
260
    private syncGeneration = 0;
159✔
261

262
    /**
263
     * Get a promise that resolves when this manager is finished initializing
264
     */
265
    public onInitialized() {
266
        return Promise.allSettled([
156✔
267
            //wait for the first sync to finish
268
            this.firstSync.promise,
269
            //make sure we're not in the middle of a sync
270
            this.syncPromise,
271
            //make sure all projects are activated
272
            ...this.projects.map(x => x.whenActivated())
177✔
273
        ]);
274
    }
275
    /**
276
     * Get a promise that resolves when the project manager is idle (no pending work)
277
     */
278
    public async onIdle() {
279
        await this.onInitialized();
39✔
280

281
        //There are race conditions where the fileChangesQueue will become idle, but that causes the documentManager
282
        //to start a new flush. So we must keep waiting until everything is idle
283
        while (!this.documentManager.isIdle || !this.fileChangesQueue.isIdle) {
39✔
284
            this.logger.debug('onIdle', { documentManagerIdle: this.documentManager.isIdle, fileChangesQueueIdle: this.fileChangesQueue.isIdle });
9✔
285

286
            await Promise.allSettled([
9✔
287
                //make sure all pending file changes have been flushed
288
                this.documentManager.onIdle(),
289
                //wait for the file changes queue to be idle
290
                this.fileChangesQueue.onIdle()
291
            ]);
292
        }
293

294
        this.logger.info('onIdle debug', { documentManagerIdle: this.documentManager.isIdle, fileChangesQueueIdle: this.fileChangesQueue.isIdle });
39✔
295
    }
296

297
    /**
298
     * Given a list of all desired projects, create any missing projects and destroy and projects that are no longer available
299
     * Treat workspaces that don't have a bsconfig.json as a project.
300
     * Handle situations where bsconfig.json files were added or removed (to elevate/lower workspaceFolder projects accordingly)
301
     * Leave existing projects alone if they are not affected by these changes
302
     * @param workspaceConfigs an array of workspaces
303
     */
304
    @TrackBusyStatus
305
    public async syncProjects(workspaceConfigs: WorkspaceConfig[], forceReload = false) {
1✔
306
        //if we're force reloading, destroy all projects and start fresh
307
        if (forceReload) {
104✔
308
            this.logger.log('syncProjects: forceReload is true so removing all existing projects');
3✔
309
            for (const project of this.projects) {
3✔
310
                this.removeProject(project);
4✔
311
            }
312
        }
313
        this.logger.log('syncProjects', workspaceConfigs.map(x => x.workspaceFolder));
104✔
314

315
        const generation = ++this.syncGeneration;
104✔
316

317
        this.syncPromise = (async () => {
104✔
318
            //build a list of unique projects across all workspace folders
319
            let projectConfigs = (await Promise.all(
104✔
320
                workspaceConfigs.map(async workspaceConfig => {
321
                    const discoveredProjects = await this.discoverProjectsForWorkspace(workspaceConfig);
103✔
322
                    return discoveredProjects.map<ProjectConfig>(discoveredProject => ({
174✔
323
                        name: discoveredProject?.name,
522!
324
                        projectKey: s`${discoveredProject.bsconfigPath ?? discoveredProject.dir}`,
522✔
325
                        projectDir: s`${discoveredProject.dir}`,
326
                        bsconfigPath: discoveredProject?.bsconfigPath,
522!
327
                        workspaceFolder: s`${workspaceConfig.workspaceFolder}`,
328
                        excludePatterns: workspaceConfig.excludePatterns,
329
                        enableThreading: workspaceConfig.languageServer.enableThreading
330
                    }));
331
                })
332
            )).flat(1);
333

334
            //TODO handle when a project came from the workspace config .projects array (it should probably never be filtered out)
335
            //filter the project paths to only include those that are allowed by the path filterer
336
            projectConfigs = this.pathFilterer.filter(projectConfigs, x => x.projectKey);
104✔
337

338
            //delete projects not represented in the list
339
            for (const project of this.projects) {
104✔
340
                //we can't find this existing project in our new list, so scrap it
341
                if (!projectConfigs.find(x => x.projectKey === project.projectKey)) {
20✔
342
                    this.removeProject(project);
6✔
343
                }
344
            }
345

346
            // skip projects we already have (they're already loaded...no need to reload them)
347
            projectConfigs = projectConfigs.filter(x => {
104✔
348
                return !this.hasProject(x);
174✔
349
            });
350

351
            //dedupe by projectKey
352
            projectConfigs = [
104✔
353
                ...projectConfigs.reduce(
354
                    (acc, x) => acc.set(x.projectKey, x),
166✔
355
                    new Map<string, typeof projectConfigs[0]>()
356
                ).values()
357
            ];
358

359
            // Phase 1: activate projects with concurrency limit (awaited — gates LSP readiness)
360
            const activatedProjects: LspProject[] = [];
104✔
361
            await this.runWithConcurrencyLimit(projectConfigs, this.projectActivationConcurrencyLimit, async (config) => {
104✔
362
                if (this.syncGeneration !== generation) {
165✔
363
                    return;
5✔
364
                }
365
                const project = await this.createAndActivateProject(config);
160✔
366
                activatedProjects.push(project);
160✔
367
            });
368

369
            //mark that we've completed our first sync
370
            this.firstSync.tryResolve();
104✔
371

372
            // Phase 2: validate activated projects with concurrency limit (NOT awaited — doesn't block LSP requests)
373
            if (this.syncGeneration === generation) {
104✔
374
                void this.runWithConcurrencyLimit(activatedProjects, this.projectActivationConcurrencyLimit, async (project) => {
102✔
375
                    if (this.syncGeneration !== generation) {
158!
376
                        return;
×
377
                    }
378
                    await project.validate();
158✔
379
                }).catch(e => this.logger.error('Validation phase error', e));
×
380
            }
381
        })();
382

383
        //return the sync promise
384
        return this.syncPromise;
104✔
385
    }
386

387
    /**
388
     * Run async actions over a list of items with a concurrency limit.
389
     * Uses a worker-pool pattern: `concurrencyLimit` workers pull items from a shared queue.
390
     */
391
    private async runWithConcurrencyLimit<T>(items: T[], concurrencyLimit: number, action: (item: T) => Promise<void>): Promise<void> {
392
        const queue = [...items];
206✔
393
        if (queue.length === 0) {
206✔
394
            return;
14✔
395
        }
396
        const workerCount = Math.max(1, Math.min(concurrencyLimit, queue.length));
192✔
397
        const workers = Array.from({ length: workerCount }, async () => {
192✔
398
            while (queue.length > 0) {
254✔
399
                const item = queue.shift();
323✔
400
                if (item) {
323!
401
                    await action(item);
323✔
402
                }
403
            }
404
        });
405
        await Promise.all(workers);
192✔
406
    }
407

408
    private fileChangesQueue = new ActionQueue({
159✔
409
        maxActionDuration: 45_000
410
    });
411

412
    public handleFileChanges(changes: FileChange[]): Promise<void> {
413
        this.logger.debug('handleFileChanges', changes.map(x => `${FileChangeTypeLookup[x.type]}: ${x.srcPath}`));
81✔
414

415
        //this function should NOT be marked as async, because typescript wraps the body in an async call sometimes. These need to be registered synchronously
416
        return this.fileChangesQueue.run(async (changes) => {
72✔
417
            //wait for any pending syncs to finish
418
            await this.onInitialized();
72✔
419

420
            return this._handleFileChanges(changes);
72✔
421
        }, changes);
422
    }
423

424
    /**
425
     * Handle when files or directories are added, changed, or deleted in the workspace.
426
     * This is safe to call any time. Changes will be queued and flushed at the correct times
427
     */
428
    private async _handleFileChanges(changes: FileChange[]) {
429
        //normalize srcPath for all changes
430
        for (const change of changes) {
72✔
431
            change.srcPath = util.standardizePath(change.srcPath);
81✔
432
        }
433

434
        //filter any changes that are not allowed by the path filterer
435
        changes = this.pathFilterer.filter(changes, x => x.srcPath);
72✔
436

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

439
        //process all file changes in parallel
440
        await Promise.all(changes.map(async (change) => {
72✔
441
            await this.handleFileChange(change);
80✔
442
        }));
443
    }
444

445
    /**
446
     * Handle a single file change. If the file is a directory, this will recursively read all files in the directory and call `handleFileChanges` again
447
     */
448
    private async handleFileChange(change: FileChange) {
449
        if (change.type === FileChangeType.Deleted) {
84✔
450
            //mark this document or directory as deleted
451
            this.documentManager.delete(change.srcPath);
2✔
452

453
            //file added or changed
454
        } else {
455
            //if this is a new directory, read all files recursively and register those as file changes too
456
            if (util.isDirectorySync(change.srcPath)) {
82✔
457
                const files = await fastGlob('**/*', {
2✔
458
                    cwd: change.srcPath,
459
                    onlyFiles: true,
460
                    absolute: true
461
                });
462
                //pipe all files found recursively in the new directory through this same function so they can be processed correctly
463
                await Promise.all(files.map((srcPath) => {
2✔
464
                    return this.handleFileChange({
6✔
465
                        srcPath: util.standardizePath(srcPath),
466
                        type: FileChangeType.Changed,
467
                        allowStandaloneProject: change.allowStandaloneProject
468
                    });
469
                }));
470

471
                //this is a new file. set the file contents
472
            } else {
473
                this.documentManager.set({
80✔
474
                    srcPath: change.srcPath,
475
                    fileContents: change.fileContents,
476
                    allowStandaloneProject: change.allowStandaloneProject
477
                });
478
            }
479
        }
480

481
        //reload any projects whose bsconfig.json was changed
482
        const projectsToReload = this.projects.filter(project => {
84✔
483
            //this is a path to a bsconfig.json file
484
            if (project.bsconfigPath?.toLowerCase() === change.srcPath.toLowerCase()) {
98✔
485
                //fetch file contents if we don't already have them
486
                if (!change.fileContents) {
4!
487
                    try {
4✔
488
                        change.fileContents = fsExtra.readFileSync(project.bsconfigPath).toString();
4✔
489
                    } finally { }
490
                }
491
                ///the bsconfig contents have changed since we last saw it, so reload this project
492
                if (project.bsconfigFileContents !== change.fileContents) {
4✔
493
                    return true;
3✔
494
                }
495
            }
496
            //this is a path to a manifest file
497
            if (project.manifestSrcPath?.toLowerCase() === change.srcPath.toLowerCase()) {
95✔
498
                //try to read the manifest file contents. If this fails (e.g. the file was deleted), manifestFileContents stays undefined,
499
                //which will still trigger a reload when compared against the previously loaded contents
500
                let manifestFileContents: string;
501
                try {
3✔
502
                    manifestFileContents = fsExtra.readFileSync(change.srcPath).toString();
3✔
503
                } catch { }
504
                //the manifest contents have changed since we last saw it, so reload this project
505
                if (project.manifestFileContents !== manifestFileContents) {
3✔
506
                    return true;
2✔
507
                }
508
            }
509
            return false;
93✔
510
        });
511

512
        if (projectsToReload.length > 0) {
84✔
513
            await Promise.all(
5✔
514
                projectsToReload.map(x => this.reloadProject(x))
5✔
515
            );
516
        }
517
    }
518

519
    /**
520
     * Handle when a file is closed in the editor (this mostly just handles removing standalone projects)
521
     */
522
    public async handleFileClose(event: { srcPath: string }) {
523
        this.logger.debug(`File was closed. ${event.srcPath}`);
1✔
524
        this.removeStandaloneProject(event.srcPath);
1✔
525
        //most other methods on this class are async, might as well make this one async too for consistency and future expansion
526
        await Promise.resolve();
1✔
527
    }
528

529
    /**
530
     * Given a project, forcibly reload it by removing it and re-adding it
531
     */
532
    private async reloadProject(project: LspProject) {
533
        this.logger.log('Reloading project', { projectPath: project.projectKey });
5✔
534

535
        this.removeProject(project);
5✔
536
        project = await this.createAndActivateProject(project.activateOptions);
5✔
537
        void project.validate();
5✔
538
    }
539

540
    /**
541
     * Get all the semantic tokens for the given file
542
     * @returns an array of semantic tokens
543
     */
544
    @TrackBusyStatus
545
    public async getSemanticTokens(options: { srcPath: string }) {
1✔
546
        //wait for all pending syncs to finish
547
        await this.onIdle();
1✔
548

549
        let result = await util.promiseRaceMatch(
1✔
550
            this.projects.map(x => x.getSemanticTokens(options)),
1✔
551
            //keep the first non-falsey result
552
            (result) => result?.length > 0
1!
553
        );
554
        return result;
1✔
555
    }
556

557
    /**
558
     * Get a string containing the transpiled contents of the file at the given path
559
     * @returns the transpiled contents of the file as a string
560
     */
561
    @TrackBusyStatus
562
    public async transpileFile(options: { srcPath: string }) {
1✔
563
        //wait for all pending syncs to finish
564
        await this.onIdle();
2✔
565

566
        let result = await util.promiseRaceMatch(
2✔
567
            this.projects.map(x => x.transpileFile(options)),
2✔
568
            //keep the first non-falsey result
569
            (result) => !!result
2✔
570
        );
571
        return result;
2✔
572
    }
573

574
    /**
575
     *  Get the completions for the given position in the file
576
     */
577
    @TrackBusyStatus
578
    public async getCompletions(options: { srcPath: string; position: Position; cancellationToken?: CancellationToken }): Promise<CompletionList> {
1✔
579
        await this.onIdle();
2✔
580

581
        //if the request has been cancelled since originally requested due to idle time being slow, skip the rest of the wor
582
        if (options?.cancellationToken?.isCancellationRequested) {
2!
583
            this.logger.debug('ProjectManager getCompletions cancelled', options);
×
584
            return;
×
585
        }
586

587
        this.logger.debug('ProjectManager getCompletions', options);
2✔
588
        //Ask every project for results, keep whichever one responds first that has a valid response
589
        let result = await util.promiseRaceMatch(
2✔
590
            this.projects.map(x => x.getCompletions(options)),
4✔
591
            //keep the first non-falsey result
592
            (result) => result?.items?.length > 0
2!
593
        );
594
        return result;
2✔
595
    }
596

597
    /**
598
     * Get the hover information for the given position in the file. If multiple projects have hover information, the projects will be raced and
599
     * the fastest result will be returned
600
     * @returns the hover information or undefined if no hover information was found
601
     */
602
    @TrackBusyStatus
603
    public async getHover(options: { srcPath: string; position: Position }): Promise<Hover> {
1✔
604
        //wait for all pending syncs to finish
605
        await this.onIdle();
1✔
606

607
        //Ask every project for hover info, keep whichever one responds first that has a valid response
608
        let hovers = await util.promiseRaceMatch(
1✔
609
            this.projects.map(x => x.getHover(options)),
1✔
610
            //keep the first set of non-empty results
611
            (result) => result?.length > 0
1!
612
        );
613

614
        let result = {
1✔
615
            contents: [],
616
            range: undefined as Range | undefined
617
        };
618

619
        //consolidate all hover results into a single hover
620
        for (const hover of hovers ?? []) {
1!
621
            if (typeof hover?.contents === 'string') {
2!
622
                result.contents.push(hover.contents);
×
623
            } else if (Array.isArray(hover?.contents)) {
2!
624
                result.contents.push(...hover.contents);
2✔
625
            }
626

627
            if (!result.range && hover.range) {
2✔
628
                result.range = hover.range;
1✔
629
            }
630
            result.range = util.createBoundingRange(result.range, hover.range);
2✔
631
        }
632

633
        //now only keep unique hovers
634
        result.contents = [...new Set(result.contents)];
1✔
635
        return result;
1✔
636
    }
637

638
    /**
639
     * Get the definition for the symbol at the given position in the file
640
     * @returns a list of locations where the symbol under the position is defined in the project
641
     */
642
    @TrackBusyStatus
643
    public async getDefinition(options: { srcPath: string; position: Position }): Promise<Location[]> {
1✔
644
        //wait for all pending syncs to finish
645
        await this.onIdle();
5✔
646

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

649
        //Ask every project for definition info, keep whichever one responds first that has a valid response
650
        let result = await util.promiseRaceMatch(
5✔
651
            this.projects.map(x => x.getDefinition(options)),
5✔
652
            //keep the first non-falsey result
653
            (result) => !!result
5✔
654
        );
655
        return result;
5✔
656
    }
657

658
    @TrackBusyStatus
659
    public async getSignatureHelp(options: { srcPath: string; position: Position }): Promise<SignatureHelp> {
1✔
660
        //wait for all pending syncs to finish
661
        await this.onIdle();
4✔
662

663
        //Ask every project for definition info, keep whichever one responds first that has a valid response
664
        let signatures = await util.promiseRaceMatch(
4✔
665
            this.projects.map(x => x.getSignatureHelp(options)),
4✔
666
            //keep the first non-falsey result
667
            (result) => !!result
4✔
668
        );
669

670
        if (signatures?.length > 0) {
4!
671
            const activeSignature = signatures.length > 0 ? 0 : undefined;
3!
672

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

675
            let result: SignatureHelp = {
3✔
676
                signatures: signatures.map((s) => s.signature),
3✔
677
                activeSignature: activeSignature,
678
                activeParameter: activeParameter
679
            };
680
            return result;
3✔
681
        }
682
    }
683

684
    @TrackBusyStatus
685
    public async getDocumentSymbol(options: { srcPath: string }): Promise<DocumentSymbol[]> {
1✔
686
        //wait for all pending syncs to finish
687
        await this.onIdle();
6✔
688

689
        //Ask every project for definition info, keep whichever one responds first that has a valid response
690
        let result = await util.promiseRaceMatch(
6✔
691
            this.projects.map(x => x.getDocumentSymbol(options)),
6✔
692
            //keep the first non-falsey result
693
            (result) => !!result
6✔
694
        );
695
        return result;
6✔
696
    }
697

698
    @TrackBusyStatus
699
    public async getWorkspaceSymbol(): Promise<WorkspaceSymbol[]> {
1✔
700
        //wait for all pending syncs to finish
701
        await this.onIdle();
5✔
702

703
        //Ask every project for definition info, keep whichever one responds first that has a valid response
704
        let responses = await Promise.allSettled(
5✔
705
            this.projects.map(x => x.getWorkspaceSymbol())
5✔
706
        );
707
        let results = responses
5✔
708
            //keep all symbol results
709
            .map((x) => {
710
                return x.status === 'fulfilled' ? x.value : [];
5!
711
            })
712
            //flatten the array
713
            .flat()
714
            //throw out nulls
715
            .filter(x => !!x);
24✔
716

717
        // Remove duplicates
718
        const allSymbols = Object.values(
5✔
719
            results.reduce((map, symbol) => {
720
                const key = symbol.location.uri + symbol.name;
24✔
721
                map[key] = symbol;
24✔
722
                return map;
24✔
723
            }, {})
724
        );
725

726
        return allSymbols as SymbolInformation[];
5✔
727
    }
728

729
    @TrackBusyStatus
730
    public async getReferences(options: { srcPath: string; position: Position }): Promise<Location[]> {
1✔
731
        //wait for all pending syncs to finish
732
        await this.onIdle();
3✔
733

734
        //Ask every project for definition info, keep whichever one responds first that has a valid response
735
        let result = await util.promiseRaceMatch(
3✔
736
            this.projects.map(x => x.getReferences(options)),
3✔
737
            //keep the first non-falsey result
738
            (result) => !!result
3✔
739
        );
740
        return result ?? [];
3!
741
    }
742

743
    /**
744
     * Collect file-rename text edits from every project and reconcile them.
745
     * If two projects produce different replacement text for the same (uri, range), drop that edit
746
     * to avoid mangling source. Otherwise emit the agreed-upon edit once.
747
     */
748
    @TrackBusyStatus
749
    public async getFileRenameEdits(options: { oldSrcPath: string; newSrcPath: string }): Promise<FileRenameTextEdit[]> {
1✔
750
        await this.onIdle();
7✔
751

752
        const perProjectEdits = await Promise.all(
7✔
753
            this.projects.map(async project => {
754
                try {
11✔
755
                    return await project.getFileRenameEdits(options);
11✔
756
                } catch (error) {
757
                    this.logger.debug(`[${util.getProjectLogName(project)}] getFileRenameEdits failed`, error);
1✔
758
                    return [] as FileRenameTextEdit[];
1✔
759
                }
760
            })
761
        );
762

763
        const editsByKey = new Map<string, { edit: FileRenameTextEdit; agreedNewText: string | null }>();
7✔
764
        for (const projectEdits of perProjectEdits) {
7✔
765
            for (const edit of projectEdits ?? []) {
11!
766
                const key = `${edit.uri.toLowerCase()}|${edit.range.start.line}:${edit.range.start.character}-${edit.range.end.line}:${edit.range.end.character}`;
8✔
767
                const existing = editsByKey.get(key);
8✔
768
                if (!existing) {
8✔
769
                    editsByKey.set(key, { edit: edit, agreedNewText: edit.newText });
6✔
770
                } else if (existing.agreedNewText !== null && existing.agreedNewText !== edit.newText) {
2✔
771
                    //projects disagree on the replacement — drop the edit rather than risk corrupting source
772
                    existing.agreedNewText = null;
1✔
773
                }
774
            }
775
        }
776

777
        const result: FileRenameTextEdit[] = [];
7✔
778
        for (const { edit, agreedNewText } of editsByKey.values()) {
7✔
779
            if (agreedNewText === null) {
6✔
780
                this.logger.debug('Dropping file-rename edit due to cross-project disagreement', edit);
1✔
781
                continue;
1✔
782
            }
783
            result.push({ uri: edit.uri, range: edit.range, newText: agreedNewText });
5✔
784
        }
785
        return result;
7✔
786
    }
787

788
    @TrackBusyStatus
789
    public async getCodeActions(options: { srcPath: string; range: Range }) {
1✔
790
        //wait for all pending syncs to finish
791
        await this.onIdle();
×
792

793
        //Ask every project for definition info, keep whichever one responds first that has a valid response
794
        let result = await util.promiseRaceMatch(
×
795
            this.projects.map(x => x.getCodeActions(options)),
×
796
            //keep the first non-falsey result
797
            (result) => !!result
×
798
        );
799
        return result;
×
800
    }
801

802
    @TrackBusyStatus
803
    public async getFixAllCodeActions(options: { srcPath: string }) {
1✔
804
        //wait for all pending syncs to finish
805
        await this.onIdle();
2✔
806

807
        let result = await util.promiseRaceMatch(
2✔
808
            this.projects.map(x => x.getSourceFixAllCodeActions(options)),
2✔
809
            (result) => !!result
2✔
810
        );
811
        return result;
2✔
812
    }
813

814
    @TrackBusyStatus
815
    public async getSelectionRanges(options: { srcPath: string; positions: Position[] }): Promise<SelectionRange[]> {
1✔
816
        //wait for all pending syncs to finish
817
        await this.onIdle();
×
818

819
        //Ask every project for selection ranges, keep whichever one responds first with a non-empty result
820
        let result = await util.promiseRaceMatch(
×
821
            this.projects.map(x => x.getSelectionRanges(options)),
×
822
            //keep the first non-empty result
823
            (result) => result?.length > 0
×
824
        );
825
        return result ?? [];
×
826
    }
827

828
    /**
829
     * Scan a given workspace for all `bsconfig.json` files. If at least one is found, then only folders who have bsconfig.json are returned.
830
     * If none are found, then the workspaceFolder itself is treated as a project
831
     */
832
    private async discoverProjectsForWorkspace(workspaceConfig: WorkspaceConfig): Promise<DiscoveredProject[]> {
833
        //config may provide a list of project paths. If we have these, no other discovery is permitted
834
        if (Array.isArray(workspaceConfig.projects) && workspaceConfig.projects.length > 0) {
103✔
835
            this.logger.debug(`Using project paths from workspace config`, workspaceConfig.projects);
3✔
836
            const projectConfigs = workspaceConfig.projects.reduce<DiscoveredProject[]>((acc, project) => {
3✔
837
                //skip this project if it's disabled or we don't have a path
838
                if (project.disabled || !project.path) {
5!
839
                    return acc;
×
840
                }
841
                //ensure the project path is absolute
842
                if (!path.isAbsolute(project.path)) {
5!
843
                    project.path = path.resolve(workspaceConfig.workspaceFolder, project.path);
×
844
                }
845

846
                //skip this project if the path does't exist
847
                if (!fsExtra.existsSync(project.path)) {
5!
848
                    return acc;
×
849
                }
850

851
                //if the project is a directory
852
                if (fsExtra.statSync(project.path).isDirectory()) {
5✔
853
                    acc.push({
2✔
854
                        name: project.name,
855
                        bsconfigPath: undefined,
856
                        dir: project.path
857
                    });
858
                    //it's a path to a file (hopefully bsconfig.json)
859
                } else {
860
                    acc.push({
3✔
861
                        name: project.name,
862
                        dir: path.dirname(project.path),
863
                        bsconfigPath: project.path
864
                    });
865
                }
866
                return acc;
5✔
867
            }, []);
868

869
            //if we didn't find any valid project paths, log a warning. having zero projects is acceptable, it typically means the user wanted to disable discovery or
870
            //disabled all their projects on purpose
871
            if (projectConfigs.length === 0) {
3!
872
                this.logger.warn(`No valid project paths found in workspace config`, JSON.stringify(workspaceConfig.projects, null, 4));
×
873
            }
874
            return projectConfigs;
3✔
875
        }
876

877
        //automatic discovery disabled?
878
        if (!workspaceConfig.languageServer.enableProjectDiscovery) {
100✔
879
            return [{
2✔
880
                dir: workspaceConfig.workspaceFolder
881
            }];
882
        }
883

884
        //get the list of exclude patterns, negate them so they actually work like excludes), and coerce to forward slashes since that's what fast-glob expects
885
        const excludePatterns = (workspaceConfig.excludePatterns ?? []).map(x => s`!${x}`.replace(/[\\/]+/g, '/'));
98✔
886

887
        let files = await fastGlob(['**/bsconfig.json', ...excludePatterns], {
98✔
888
            cwd: workspaceConfig.workspaceFolder,
889
            followSymbolicLinks: false,
890
            absolute: true,
891
            onlyFiles: true,
892
            deep: workspaceConfig.languageServer.projectDiscoveryMaxDepth ?? 15
294✔
893
        });
894

895
        //filter the files to only include those that are allowed by the path filterer
896
        files = this.pathFilterer.filter(files);
98✔
897

898
        //if we found at least one bsconfig.json, then ALL projects must have a bsconfig.json.
899
        if (files.length > 0) {
98✔
900
            return files.map(file => ({
105✔
901
                dir: s`${path.dirname(file)}`,
902
                bsconfigPath: s`${file}`
903
            }));
904
        }
905

906
        //look for roku project folders
907
        let rokuLikeDirs = (await Promise.all(
53✔
908
            //find all folders containing a `manifest` file
909
            (await fastGlob(['**/manifest', ...excludePatterns], {
910
                cwd: workspaceConfig.workspaceFolder,
911
                followSymbolicLinks: false,
912
                absolute: true,
913
                onlyFiles: true,
914
                deep: workspaceConfig.languageServer.projectDiscoveryMaxDepth ?? 15
159✔
915
            })).map(async manifestEntry => {
916
                const manifestDir = path.dirname(manifestEntry);
19✔
917
                //TODO validate that manifest is a Roku manifest
918
                const files = await rokuDeploy.getFilePaths([
19✔
919
                    'source/**/*.{brs,bs}',
920
                    ...excludePatterns
921
                ], manifestDir);
922
                if (files.length > 0) {
19✔
923
                    return s`${manifestDir}`;
15✔
924
                }
925
            })
926
            //throw out nulls
927
        )).filter(x => !!x);
19✔
928

929
        //throw out any directories that are not allowed by the path filterer
930
        rokuLikeDirs = this.pathFilterer.filter(rokuLikeDirs, srcPath => srcPath);
53✔
931

932
        if (rokuLikeDirs.length > 0) {
53✔
933
            return rokuLikeDirs.map(file => ({
15✔
934
                dir: file
935
            }));
936
        }
937

938
        //treat the workspace folder as a brightscript project itself
939
        return [{
47✔
940
            dir: workspaceConfig.workspaceFolder
941
        }];
942
    }
943

944
    /**
945
     * Returns true if we have this project, or false if we don't
946
     * @returns true if the project exists, or false if it doesn't
947
     */
948
    private hasProject(config: Partial<ProjectConfig>) {
949
        return !!this.getProject(config);
514✔
950
    }
951

952
    /**
953
     * Get a project with the specified path
954
     * @param param path to the project or an obj that has `projectPath` prop
955
     * @returns a project, or undefined if no project was found
956
     */
957
    private getProject(param: string | Partial<ProjectConfig>) {
958
        const projectKey = util.standardizePath(
516✔
959
            (typeof param === 'string') ? param : (param?.projectKey ?? param?.bsconfigPath ?? param?.projectDir)
5,151!
960
        );
961
        if (!projectKey) {
516!
962
            return;
×
963
        }
964
        return this.projects.find(x => x.projectKey === projectKey);
516✔
965
    }
966

967
    /**
968
     * Remove a project from the language server
969
     */
970
    private removeProject(project: LspProject) {
971
        const idx = this.projects.findIndex(x => x.projectKey === project?.projectKey);
21✔
972
        if (idx > -1) {
21✔
973
            this.logger.log('Removing project', { projectKey: project.projectKey, projectNumber: project.projectNumber });
18✔
974
            this.projects.splice(idx, 1);
18✔
975
        }
976
        //anytime we remove a project, we should emit an event that clears all of its diagnostics
977
        this.emit('diagnostics', { project: project, diagnostics: [] });
21✔
978
        project?.dispose();
21✔
979
        this.busyStatusTracker.endAllRunsForScope(project);
21✔
980
    }
981

982
    /**
983
     * A unique project counter to help distinguish log entries in lsp mode
984
     */
985
    private static projectNumberSequence = 0;
1✔
986

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

989
    /**
990
     * Get a projectNumber for a given config. Try to reuse project numbers when we've seen this project before
991
     *  - If the config already has one, use that.
992
     *  - If we've already seen this config before, use the same project number as before
993
     */
994
    private getProjectNumber(config: ProjectConfig) {
995
        if (config.projectNumber !== undefined) {
172✔
996
            return config.projectNumber;
11✔
997
        }
998
        const key = s`${config.projectKey}` + '-' + s`${config.workspaceFolder}` + '-' + s`${config.bsconfigPath}`;
161✔
999
        return ProjectManager.projectNumberCache.getOrAdd(key, () => {
161✔
1000
            return ProjectManager.projectNumberSequence++;
32✔
1001
        });
1002
    }
1003

1004
    /**
1005
     * Constructs a project for the given config. Just makes the project, doesn't activate it
1006
     * @returns a new project, or the existing project if one already exists with this config info
1007
     */
1008
    private constructProject(config: ProjectConfig): LspProject {
1009
        //skip this project if we already have it
1010
        if (this.hasProject(config)) {
172!
1011
            return this.getProject(config);
×
1012
        }
1013

1014
        config.projectNumber = this.getProjectNumber(config);
172✔
1015

1016
        let project: LspProject = config.enableThreading
172✔
1017
            ? new WorkerThreadProject({
172✔
1018
                logger: this.logger.createLogger()
1019
            })
1020
            : new Project({
1021
                logger: this.logger.createLogger()
1022
            });
1023

1024
        this.logger.log(`Created project #${config.projectNumber} for: "${config.projectKey}" (${config.enableThreading ? 'worker thread' : 'main thread'})`);
172✔
1025

1026
        this.projects.push(project);
172✔
1027

1028
        //pipe all project-specific events through our emitter, and include the project reference
1029
        project.on('all', (eventName, data) => {
172✔
1030
            this.emit(eventName as any, {
805✔
1031
                ...data,
1032
                project: project
1033
            } as any);
1034
        });
1035
        return project;
172✔
1036
    }
1037

1038
    /**
1039
     * Constructs a project for the given config
1040
     * @returns a new project, or the existing project if one already exists with this config info
1041
     */
1042
    @TrackBusyStatus
1043
    private async createAndActivateProject(config: ProjectConfig): Promise<LspProject> {
1✔
1044
        //skip this project if we already have it
1045
        if (this.hasProject(config)) {
168✔
1046
            return this.getProject(config.projectKey);
1✔
1047
        }
1048
        const project = this.constructProject(config);
167✔
1049
        await this.activateProject(project, config);
167✔
1050
        return project;
166✔
1051
    }
1052

1053
    @TrackBusyStatus
1054
    private async activateProject(project: LspProject, config: ProjectConfig) {
1✔
1055
        this.logger.debug('Activating project', util.getProjectLogName(project), {
145✔
1056
            projectPath: config?.projectKey,
435!
1057
            bsconfigPath: config.bsconfigPath
1058
        });
1059
        await project.activate(config);
145✔
1060

1061
        //send an event to indicate that this project has been activated
1062
        this.emit('project-activate', { project: project });
144✔
1063

1064
        //register this project's list of files with the path filterer
1065
        const unregister = this.pathFilterer.registerIncludeList(project.rootDir, project.filePatterns);
144✔
1066
        project.disposables.push({ dispose: unregister });
144✔
1067
    }
1068

1069
    public on(eventName: 'validate-begin', handler: (data: { project: LspProject }) => MaybePromise<void>);
1070
    public on(eventName: 'validate-end', handler: (data: { project: LspProject }) => MaybePromise<void>);
1071
    public on(eventName: 'critical-failure', handler: (data: { project: LspProject; message: string }) => MaybePromise<void>);
1072
    public on(eventName: 'project-activate', handler: (data: { project: LspProject }) => MaybePromise<void>);
1073
    public on(eventName: 'diagnostics', handler: (data: { project: LspProject; diagnostics: LspDiagnostic[] }) => MaybePromise<void>);
1074
    public on(eventName: string, handler: (payload: any) => MaybePromise<void>) {
1075
        this.emitter.on(eventName, handler as any);
573✔
1076
        return () => {
573✔
1077
            this.emitter.removeListener(eventName, handler as any);
1✔
1078
        };
1079
    }
1080

1081
    private emit(eventName: 'validate-begin', data: { project: LspProject });
1082
    private emit(eventName: 'validate-end', data: { project: LspProject });
1083
    private emit(eventName: 'critical-failure', data: { project: LspProject; message: string });
1084
    private emit(eventName: 'project-activate', data: { project: LspProject });
1085
    private emit(eventName: 'diagnostics', data: { project: LspProject; diagnostics: LspDiagnostic[] });
1086
    private async emit(eventName: string, data?) {
1087
        //emit these events on next tick, otherwise they will be processed immediately which could cause issues
1088
        await util.sleep(0);
975✔
1089
        this.emitter.emit(eventName, data);
975✔
1090
    }
1091
    private emitter = new EventEmitter();
159✔
1092

1093
    public dispose() {
1094
        this.emitter.removeAllListeners();
159✔
1095
        for (const project of this.projects) {
159✔
1096
            project?.dispose?.();
164!
1097
        }
1098
    }
1099
}
1100

1101
export interface WorkspaceConfig {
1102
    /**
1103
     * Absolute path to the folder where the workspace resides
1104
     */
1105
    workspaceFolder: string;
1106
    /**
1107
     * A list of glob patterns used to _exclude_ files from various bsconfig searches
1108
     */
1109
    excludePatterns?: string[];
1110
    /**
1111
     * A list of project paths that should be used to create projects in place of discovery.
1112
     */
1113
    projects?: BrightScriptProjectConfiguration[];
1114
    /**
1115
     * Language server configuration options
1116
     */
1117
    languageServer: {
1118
        /**
1119
         * Should the projects in this workspace be run in their own dedicated worker threads, or all run on the main thread
1120
         */
1121
        enableThreading: boolean;
1122
        /**
1123
         * Should the language server automatically discover projects in this workspace?
1124
         */
1125
        enableProjectDiscovery: boolean;
1126
        /**
1127
         * A list of glob patterns used to _exclude_ files from project discovery
1128
         */
1129
        projectDiscoveryExclude?: Record<string, boolean>;
1130
        /**
1131
         * The log level to use for this workspace
1132
         */
1133
        logLevel?: LogLevel | string;
1134
        /**
1135
         * Maximum depth to search for Roku projects
1136
         */
1137
        projectDiscoveryMaxDepth?: number;
1138
        /**
1139
         * The maximum number of projects that can be activated concurrently
1140
         */
1141
        projectActivationConcurrencyLimit?: number;
1142
    };
1143
}
1144

1145
interface StandaloneProject extends LspProject {
1146
    /**
1147
     * The path to the file that this project represents
1148
     */
1149
    srcPath: string;
1150
}
1151

1152
/**
1153
 * An annotation used to wrap the method in a busyStatus tracking call
1154
 */
1155
function TrackBusyStatus(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
1156
    let originalMethod = descriptor.value;
17✔
1157

1158
    //wrapping the original method
1159
    descriptor.value = function value(this: ProjectManager, ...args: any[]) {
17✔
1160
        return this.busyStatusTracker.run(() => {
500✔
1161
            return originalMethod.apply(this, args);
500✔
1162
        }, originalMethod.name);
1163
    };
1164
}
1165

1166
interface DiscoveredProject {
1167
    name?: string;
1168
    bsconfigPath?: string;
1169
    dir: string;
1170
}
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