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

rokucommunity / brighterscript / #14176

10 Apr 2025 04:25PM UTC coverage: 87.117% (-2.0%) from 89.104%
#14176

push

web-flow
Merge 2bd224618 into 2b6cc17a0

13313 of 16151 branches covered (82.43%)

Branch coverage included in aggregate %.

7993 of 8671 new or added lines in 102 files covered. (92.18%)

84 existing lines in 22 files now uncovered.

14385 of 15643 relevant lines covered (91.96%)

19746.54 hits per line

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

84.83
/src/lsp/Project.ts
1
import { ProgramBuilder } from '../ProgramBuilder';
1✔
2
import * as EventEmitter from 'eventemitter3';
1✔
3
import util, { standardizePath as s } from '../util';
1✔
4
import * as path from 'path';
1✔
5
import type { ProjectConfig, ActivateResponse, LspDiagnostic, LspProject } from './LspProject';
6
import type { Plugin, Hover, MaybePromise } from '../interfaces';
7
import { Deferred } from '../deferred';
1✔
8
import type { StandardizedFileEntry } from 'roku-deploy';
9
import { rokuDeploy } from 'roku-deploy';
1✔
10
import type { DocumentSymbol, Position, Range, Location, WorkspaceSymbol } from 'vscode-languageserver-protocol';
11
import { CompletionList } from 'vscode-languageserver-protocol';
1✔
12
import { CancellationTokenSource } from 'vscode-languageserver-protocol';
1✔
13
import type { DocumentAction, DocumentActionWithStatus } from './DocumentManager';
14
import type { SignatureInfoObj } from '../Program';
15
import type { BsConfig } from '../BsConfig';
16
import type { Logger, LogLevel } from '../logging';
17
import { createLogger } from '../logging';
1✔
18
import * as fsExtra from 'fs-extra';
1✔
19
import type { XmlFile } from '../files/XmlFile';
20
import type { BrsFile } from '../files/BrsFile';
21

22
export class Project implements LspProject {
1✔
23
    public constructor(
24
        options?: {
25
            logger?: Logger;
26
            projectIdentifier?: string;
27
        }
28
    ) {
29
        this.logger = options?.logger ?? createLogger({
87✔
30
            //when running inside a worker thread, we don't want to use colors
31
            enableColor: false
32
        });
33
        this.projectIdentifier = options?.projectIdentifier ?? '';
87✔
34
    }
35

36
    /**
37
     * Activates this project. Every call to `activate` should completely reset the project, clear all used ram and start from scratch.
38
     */
39
    public async activate(options: ProjectConfig): Promise<ActivateResponse> {
40
        this.logger.info('Project.activate', options.projectPath);
83✔
41

42
        this.activateOptions = options;
83✔
43
        this.projectPath = options.projectPath ? util.standardizePath(options.projectPath) : options.projectPath;
83!
44
        this.workspaceFolder = options.workspaceFolder ? util.standardizePath(options.workspaceFolder) : options.workspaceFolder;
83✔
45
        this.projectNumber = options.projectNumber;
83✔
46
        this.bsconfigPath = await this.getConfigFilePath(options);
83✔
47

48
        this.builder = new ProgramBuilder({
83✔
49
            //share our logger with ProgramBuilder, which will keep our level in sync with the one in the program
50
            logger: this.logger
51
        });
52

53
        this.builder.logger.prefix = `[${this.projectIdentifier}]`;
83✔
54
        this.disposables.push(this.builder);
83✔
55

56
        let cwd: string;
57
        //if the config file exists, use it and its folder as cwd
58
        if (this.bsconfigPath && await util.pathExists(this.bsconfigPath)) {
83✔
59
            cwd = path.dirname(this.bsconfigPath);
34✔
60
            //load the bsconfig file contents (used for performance optimizations externally)
61
            try {
34✔
62
                this.bsconfigFileContents = (await fsExtra.readFile(this.bsconfigPath)).toString();
34✔
63
            } catch { }
64

65
        } else {
66
            cwd = this.projectPath;
49✔
67
            //config file doesn't exist...let `brighterscript` resolve the default way
68
            this.bsconfigPath = undefined;
49✔
69
        }
70

71
        const builderOptions = {
83✔
72
            cwd: cwd,
73
            project: this.bsconfigPath,
74
            watch: false,
75
            createPackage: false,
76
            deploy: false,
77
            copyToStaging: false,
78
            showDiagnosticsInConsole: false,
79
            validate: false
80
        } as BsConfig;
81

82
        //Assign .files (mostly used for standalone projects) if available, as a dedicated assignment because `undefined` overrides the default value in the `bsconfig.json`
83
        if (options.files) {
83✔
84
            builderOptions.files = rokuDeploy.normalizeFilesArray(options.files);
5✔
85
        }
86

87
        //run the builder to initialize the program. Skip validation for now, we'll trigger it soon in a more cancellable way
88
        await this.builder.run({
83✔
89
            ...builderOptions,
90
            validate: false,
91
            //don't show diagnostics in the console since this is run via the language server, they're presented in a different way
92
            showDiagnosticsInConsole: false
93
        });
94

95
        //flush diagnostics every time the program finishes validating
96
        //this plugin must be added LAST to the program to ensure we can see all diagnostics
97
        this.builder.plugins.add({
83✔
98
            name: 'bsc-language-server',
99
            afterProgramValidate: () => {
100
                const diagnostics = this.getDiagnostics();
119✔
101
                this.emit('diagnostics', {
119✔
102
                    diagnostics: diagnostics
103
                });
104
            }
105
        } as Plugin);
106

107
        //trigger a validation (but don't wait for it. That way we can cancel it sooner if we get new incoming data or requests)
108
        void this.validate();
83✔
109

110
        this.activationDeferred.resolve();
83✔
111

112
        return {
83✔
113
            bsconfigPath: this.bsconfigPath,
114
            logLevel: this.builder.program.options.logLevel as LogLevel,
115
            rootDir: this.builder.program.options.rootDir,
116
            filePatterns: this.filePatterns
117
        };
118
    }
119

120
    public isStandaloneProject = false;
87✔
121

122
    public logger: Logger;
123

124
    /**
125
     * Options used to activate this project
126
     */
127
    public activateOptions: ProjectConfig;
128

129
    public get rootDir() {
130
        return this.builder.program.options.rootDir;
123✔
131
    }
132
    /**
133
     * The file patterns from bsconfig.json that were used to find all files for this project
134
     */
135
    public get filePatterns() {
136
        if (!this.builder) {
206!
137
            return undefined;
×
138
        }
139
        const patterns = rokuDeploy.normalizeFilesArray(this.builder.program.options.files);
206✔
140
        return patterns.map(x => {
206✔
141
            return typeof x === 'string' ? x : x.src;
662✔
142
        });
143
    }
144

145
    /**
146
     * Gets resolved when the project has finished activating
147
     */
148
    private activationDeferred = new Deferred();
87✔
149

150
    /**
151
     * Promise that resolves when the project finishes activating
152
     * @returns a promise that resolves when the project finishes activating
153
     */
154
    public whenActivated() {
155
        return this.activationDeferred.promise;
212✔
156
    }
157

158
    private validationCancelToken: CancellationTokenSource;
159

160
    /**
161
     * Validate the project. This will trigger a full validation on any scopes that were changed since the last validation,
162
     * and will also eventually emit a new 'diagnostics' event that includes all diagnostics for the project.
163
     *
164
     * This will cancel any currently running validation and then run a new one.
165
     */
166
    public async validate() {
167
        this.logger.debug('Project.validate');
123✔
168

169
        this.cancelValidate();
123✔
170
        //store
171
        this.validationCancelToken = new CancellationTokenSource();
123✔
172

173
        try {
123✔
174
            this.emit('validate-begin', {});
123✔
175
            await this.builder.program.validate({
123✔
176
                async: true,
177
                cancellationToken: this.validationCancelToken.token
178
            });
179
        } finally {
180
            this.emit('validate-end', {});
123✔
181
        }
182
    }
183

184
    /**
185
     * Cancel any active running validation
186
     */
187
    public cancelValidate() {
188
        this.validationCancelToken?.cancel();
123✔
189
        delete this.validationCancelToken;
123✔
190
    }
191

192
    public getDiagnostics(): LspDiagnostic[] {
193
        const diagnostics = this.builder.getDiagnostics();
121✔
194
        return diagnostics.map(x => {
121✔
195
            const srcPath = util.uriToPath(x.location.uri);
113✔
196
            return {
113✔
197
                ...util.toDiagnostic(x, srcPath),
198
                uri: x.location.uri
199
            };
200
        });
201
    }
202

203
    /**
204
     * Promise that resolves the next time the system is idle. If the system is already idle, it will resolve immediately
205
     */
206
    private async onIdle(): Promise<void> {
207
        await Promise.all([
72✔
208
            this.activationDeferred.promise
209
        ]);
210
    }
211

212
    /**
213
     * Add or replace the in-memory contents of the file at the specified path. This is typically called as the user is typing.
214
     * This will cancel any pending validation cycles and queue a future validation cycle instead.
215
     */
216
    public async applyFileChanges(documentActions: DocumentAction[]): Promise<DocumentActionWithStatus[]> {
217
        this.logger.debug('Project.applyFileChanges', documentActions.map(x => x.srcPath));
73✔
218

219
        await this.onIdle();
43✔
220

221
        let didChangeFiles = false;
43✔
222
        const result = [...documentActions] as DocumentActionWithStatus[];
43✔
223
        // eslint-disable-next-line @typescript-eslint/prefer-for-of
224
        for (let i = 0; i < result.length; i++) {
43✔
225
            const action = result[i];
75✔
226
            let didChangeThisFile = false;
75✔
227
            //if this is a `set` and the file matches the project's files array, set it
228
            if (action.type === 'set' && Project.willAcceptFile(action.srcPath, this.builder.program.options.files, this.builder.program.options.rootDir)) {
75✔
229
                //load the file contents from disk if we don't have an in memory copy
230
                const fileContents = action.fileContents ?? util.readFileSync(action.srcPath)?.toString();
67✔
231

232
                //if we got file contents, set the file on the program
233
                if (fileContents !== undefined) {
67✔
234
                    didChangeThisFile = this.setFile(action.srcPath, fileContents);
65✔
235
                    //this file was accepted by the program
236
                    action.status = 'accepted';
65✔
237

238
                    //if we can't get file contents, apply this as a delete
239
                } else {
240
                    action.status = 'accepted';
2✔
241
                    result.push({
2✔
242
                        id: action.id,
243
                        srcPath: action.srcPath,
244
                        type: 'delete',
245
                        status: undefined,
246
                        allowStandaloneProject: false
247
                    });
248
                    continue;
2✔
249
                }
250

251
                //try to delete the file or directory
252
            } else if (action.type === 'delete') {
8✔
253
                didChangeThisFile = this.removeFileOrDirectory(action.srcPath);
5✔
254
                //if we deleted at least one file, mark this action as accepted
255
                action.status = didChangeThisFile ? 'accepted' : 'rejected';
5✔
256

257
                //we did not handle this action, so reject
258
            } else {
259
                action.status = 'rejected';
3✔
260
            }
261
            didChangeFiles = didChangeFiles || didChangeThisFile;
73✔
262
        }
263
        if (didChangeFiles) {
43✔
264
            //trigger a validation (but don't wait for it. That way we can cancel it sooner if we get new incoming data or requests)
265
            this.validate().catch(e => this.logger.error(e));
34✔
266
        }
267

268
        this.logger.debug('project.applyFileChanges done', documentActions.map(x => x.srcPath));
73✔
269

270
        return result;
43✔
271
    }
272

273
    /**
274
     * Determine if this project will accept the file at the specified path (i.e. does it match a pattern in the project's files array)
275
     */
276
    public static willAcceptFile(srcPath: string, files: BsConfig['files'], rootDir: string) {
277
        srcPath = util.standardizePath(srcPath);
70✔
278
        if (rokuDeploy.getDestPath(srcPath, files, rootDir) !== undefined) {
70✔
279
            return true;
67✔
280
            //is this exact path in the `files` array? (this check is mostly for standalone projects)
281
        } else if ((files as StandardizedFileEntry[]).find(x => s`${x.src}` === srcPath)) {
4!
282
            return true;
×
283
        }
284
        return false;
3✔
285
    }
286

287
    /**
288
     * Set new contents for a file. This is safe to call any time. Changes will be queued and flushed at the correct times
289
     * during the program's lifecycle flow
290
     * @param srcPath absolute source path of the file
291
     * @param fileContents the text contents of the file
292
     * @returns true if this program accepted and added the file. false if the program didn't want the file, or if the contents didn't change
293
     */
294
    private setFile(srcPath: string, fileContents: string) {
295
        this.logger.debug('Project.setFile', { srcPath: srcPath, fileContentsLength: fileContents.length });
65✔
296

297
        const { files, rootDir } = this.builder.program.options;
65✔
298

299
        //get the dest path for this file.
300
        let destPath = rokuDeploy.getDestPath(srcPath, files, rootDir);
65✔
301

302
        //if we have a file and the contents haven't changed
303
        let file = this.builder.program.getFile<XmlFile | BrsFile>(destPath);
65✔
304
        if (file && file.fileContents === fileContents) {
65✔
305
            return false;
7✔
306
        }
307

308
        //if we got a dest path, then the program wants this file
309
        if (destPath) {
58!
310
            this.builder.program.setFile(
58✔
311
                {
312
                    src: srcPath,
313
                    dest: destPath
314
                },
315
                fileContents
316
            );
317
            return true;
58✔
318
        }
319
        return false;
×
320
    }
321

322
    /**
323
     * Remove the in-memory file at the specified path. This is typically called when the user (or file system watcher) triggers a file delete
324
     * @param srcPath absolute path to the File
325
     * @returns true if we found and removed at least one file, or false if no files were removed
326
     */
327
    private removeFileOrDirectory(srcPath: string) {
328
        this.logger.debug('Project.removeFileOrDirectory', srcPath);
5✔
329

330
        srcPath = util.standardizePath(srcPath);
5✔
331
        //if this is a direct file match, remove the file
332
        if (this.builder.program.hasFile(srcPath)) {
5✔
333
            this.builder.program.removeFile(srcPath);
1✔
334
            return true;
1✔
335
        }
336

337
        //maybe this is a directory. Remove all files that start with this path
338
        let removedSomeFiles = false;
4✔
339
        let lowerSrcPath = srcPath.toLowerCase();
4✔
340
        for (let file of Object.values(this.builder.program.files)) {
4✔
341
            //if the file path starts with the parent path and the file path does not exactly match the folder path
342
            if (file.srcPath?.toLowerCase().startsWith(lowerSrcPath)) {
4!
343
                this.logger.debug('Project.removeFileOrDirectory removing file because it matches the given directory', { dir: srcPath, srcPath: file.srcPath });
3✔
344

345
                this.builder.program.removeFile(file.srcPath, false);
3✔
346
                removedSomeFiles = true;
3✔
347
            }
348
        }
349
        //return true if we removed at least one file
350
        return removedSomeFiles;
4✔
351
    }
352

353
    /**
354
     * Get the full list of semantic tokens for the given file path
355
     * @param options options for getting semantic tokens
356
     * @param options.srcPath absolute path to the source file
357
     */
358
    public async getSemanticTokens(options: { srcPath: string }) {
359
        await this.onIdle();
1✔
360
        if (this.builder.program.hasFile(options.srcPath)) {
1!
361
            return this.builder.program.getSemanticTokens(options.srcPath);
1✔
362
        }
363
    }
364

365
    public async transpileFile(options: { srcPath: string }) {
366
        await this.onIdle();
2✔
367
        if (this.builder.program.hasFile(options.srcPath)) {
2!
368
            return this.builder.program.getTranspiledFileContents(options.srcPath);
2✔
369
        }
370
    }
371

372
    public async getHover(options: { srcPath: string; position: Position }): Promise<Hover[]> {
373
        await this.onIdle();
×
374
        if (this.builder.program.hasFile(options.srcPath)) {
×
375
            return this.builder.program.getHover(options.srcPath, options.position);
×
376
        }
377
    }
378

379
    public async getDefinition(options: { srcPath: string; position: Position }): Promise<Location[]> {
380
        await this.onIdle();
5✔
381
        if (this.builder.program.hasFile(options.srcPath)) {
5!
382
            return this.builder.program.getDefinition(options.srcPath, options.position);
5✔
383
        }
384
    }
385

386
    public async getSignatureHelp(options: { srcPath: string; position: Position }): Promise<SignatureInfoObj[]> {
387
        await this.onIdle();
4✔
388
        if (this.builder.program.hasFile(options.srcPath)) {
4!
389
            return this.builder.program.getSignatureHelp(options.srcPath, options.position);
4✔
390
        }
391
    }
392

393
    public async getDocumentSymbol(options: { srcPath: string }): Promise<DocumentSymbol[]> {
394
        await this.onIdle();
6✔
395
        if (this.builder.program.hasFile(options.srcPath)) {
6!
396
            return this.builder.program.getDocumentSymbols(options.srcPath);
6✔
397
        }
398
    }
399

400
    public async getWorkspaceSymbol(): Promise<WorkspaceSymbol[]> {
401
        await this.onIdle();
4✔
402
        const result = this.builder.program.getWorkspaceSymbols();
4✔
403
        return result;
4✔
404
    }
405

406
    public async getReferences(options: { srcPath: string; position: Position }) {
407
        await this.onIdle();
3✔
408
        if (this.builder.program.hasFile(options.srcPath)) {
3!
409
            return this.builder.program.getReferences(options.srcPath, options.position);
3✔
410
        }
411
    }
412

413
    public async getCodeActions(options: { srcPath: string; range: Range }) {
414
        await this.onIdle();
×
415

416
        if (this.builder.program.hasFile(options.srcPath)) {
×
417
            const codeActions = this.builder.program.getCodeActions(options.srcPath, options.range);
×
418
            //clone each diagnostic since certain diagnostics can have circular reference properties that kill the language server if serialized
419
            for (const codeAction of codeActions ?? []) {
×
420
                if (codeAction.diagnostics) {
×
421
                    codeAction.diagnostics = codeAction.diagnostics?.map(x => util.toDiagnostic(x, options.srcPath));
×
422
                }
423
            }
424
            return codeActions;
×
425
        }
426
    }
427

428
    public async getCompletions(options: { srcPath: string; position: Position }): Promise<CompletionList> {
429
        await this.onIdle();
4✔
430

431
        this.logger.debug('Project.getCompletions', options.srcPath, options.position);
4✔
432

433
        if (this.builder.program.hasFile(options.srcPath)) {
4!
434
            const completions = this.builder.program.getCompletions(options.srcPath, options.position);
4✔
435
            const result = CompletionList.create(completions);
4✔
436
            result.itemDefaults = {
4✔
437
                commitCharacters: ['.']
438
            };
439
            return result;
4✔
440
        }
441
        return undefined;
×
442
    }
443

444
    /**
445
     * Manages the BrighterScript program. The main interface into the compiler/validator
446
     */
447
    private builder: ProgramBuilder;
448

449
    /**
450
     * The path to where the project resides
451
     */
452
    public projectPath: string;
453

454
    /**
455
     * A unique number for this project, generated during this current language server session. Mostly used so we can identify which project is doing logging
456
     */
457
    public projectNumber: number;
458

459
    /**
460
     * A unique name for this project used in logs to help keep track of everything
461
     */
462
    public projectIdentifier: string;
463

464
    /**
465
     * The path to the workspace where this project resides. A workspace can have multiple projects (by adding a bsconfig.json to each folder).
466
     * Defaults to `.projectPath` if not set
467
     */
468
    public workspaceFolder: string;
469

470
    /**
471
     * Path to a bsconfig.json file that will be used for this project
472
     */
473
    public bsconfigPath?: string;
474

475
    /**
476
     * The contents of the bsconfig.json file. This is used to detect when the bsconfig file has not actually been changed (even if the fs says it did).
477
     *
478
     * Only available after `.activate()` has completed.
479
     * @deprecated do not depend on this property. This will certainly be removed in a future release
480
     */
481
    public bsconfigFileContents?: string;
482

483
    /**
484
     * Find the path to the bsconfig.json file for this project
485
     * @returns path to bsconfig.json, or undefined if unable to find it
486
     */
487
    private async getConfigFilePath(config: { configFilePath?: string; projectPath: string }) {
488
        let configFilePath: string;
489
        //if there's a setting, we need to find the file or show error if it can't be found
490
        if (config?.configFilePath) {
85✔
491
            configFilePath = path.resolve(config.projectPath, config.configFilePath);
1✔
492
            if (await util.pathExists(configFilePath)) {
1!
UNCOV
493
                return util.standardizePath(configFilePath);
×
494
            } else {
495
                this.emit('critical-failure', {
1✔
496
                    message: `Cannot find config file specified in user or workspace settings at '${configFilePath}'`
497
                });
498
            }
499
        }
500

501
        //the rest of these require a projectPath, so return early if we don't have one
502
        if (!config?.projectPath) {
85✔
503
            return undefined;
1✔
504
        }
505

506
        //default to config file path found in the root of the workspace
507
        configFilePath = s`${config.projectPath}/bsconfig.json`;
84✔
508
        if (await util.pathExists(configFilePath)) {
84✔
509
            return util.standardizePath(configFilePath);
34✔
510
        }
511

512
        //look for the deprecated `brsconfig.json` file
513
        configFilePath = s`${config.projectPath}/brsconfig.json`;
50✔
514
        if (await util.pathExists(configFilePath)) {
50!
UNCOV
515
            return util.standardizePath(configFilePath);
×
516
        }
517

518
        //no config file could be found
519
        return undefined;
50✔
520
    }
521

522
    public on(eventName: 'validate-begin', handler: (data: any) => MaybePromise<void>);
523
    public on(eventName: 'validate-end', handler: (data: any) => MaybePromise<void>);
524
    public on(eventName: 'critical-failure', handler: (data: { message: string }) => MaybePromise<void>);
525
    public on(eventName: 'diagnostics', handler: (data: { diagnostics: LspDiagnostic[] }) => MaybePromise<void>);
526
    public on(eventName: 'all', handler: (eventName: string, data: any) => MaybePromise<void>);
527
    public on(eventName: string, handler: (...args: any[]) => MaybePromise<void>) {
528
        this.emitter.on(eventName, handler as any);
81✔
529
        return () => {
81✔
530
            this.emitter.removeListener(eventName, handler as any);
4✔
531
        };
532
    }
533

534
    private emit(eventName: 'validate-begin', data: any);
535
    private emit(eventName: 'validate-end', data: any);
536
    private emit(eventName: 'critical-failure', data: { message: string });
537
    private emit(eventName: 'diagnostics', data: { diagnostics: LspDiagnostic[] });
538
    private async emit(eventName: string, data?) {
539
        //emit these events on next tick, otherwise they will be processed immediately which could cause issues
540
        await util.sleep(0);
369✔
541
        this.emitter.emit(eventName, data);
369✔
542
        //emit the 'all' event
543
        this.emitter.emit('all', eventName, data);
369✔
544
    }
545
    private emitter = new EventEmitter();
87✔
546

547
    public disposables: LspProject['disposables'] = [];
87✔
548

549
    public dispose() {
550
        for (let disposable of this.disposables ?? []) {
87!
551
            disposable?.dispose?.();
158!
552
        }
553
        this.disposables = [];
87✔
554

555
        this.emitter?.removeAllListeners();
87!
556
        if (this.activationDeferred?.isCompleted === false) {
87!
557
            this.activationDeferred.reject(
4✔
558
                new Error('Project was disposed, activation has been cancelled')
559
            );
560
        }
561
    }
562
}
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