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

rokucommunity / brighterscript / #13316

22 Nov 2024 10:13PM UTC coverage: 89.121%. Remained the same
#13316

push

web-flow
Merge 44f0280cc into c5674f5d8

7359 of 8706 branches covered (84.53%)

Branch coverage included in aggregate %.

53 of 53 new or added lines in 7 files covered. (100.0%)

55 existing lines in 5 files now uncovered.

9722 of 10460 relevant lines covered (92.94%)

1825.2 hits per line

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

86.4
/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 { CompilerPlugin, Hover, MaybePromise } from '../interfaces';
7
import { DiagnosticMessages } from '../DiagnosticMessages';
1✔
8
import { URI } from 'vscode-uri';
1✔
9
import { Deferred } from '../deferred';
1✔
10
import type { StandardizedFileEntry } from 'roku-deploy';
11
import { rokuDeploy } from 'roku-deploy';
1✔
12
import type { DocumentSymbol, Position, Range, Location, WorkspaceSymbol } from 'vscode-languageserver-protocol';
13
import { CompletionList } from 'vscode-languageserver-protocol';
1✔
14
import { CancellationTokenSource } from 'vscode-languageserver-protocol';
1✔
15
import type { DocumentAction, DocumentActionWithStatus } from './DocumentManager';
16
import type { SignatureInfoObj } from '../Program';
17
import type { BsConfig } from '../BsConfig';
18
import type { Logger, LogLevel } from '../logging';
19
import { createLogger } from '../logging';
1✔
20
import * as fsExtra from 'fs-extra';
1✔
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({
85✔
30
            //when running inside a worker thread, we don't want to use colors
31
            enableColor: false
32
        });
33
        this.projectIdentifier = options?.projectIdentifier ?? '';
85✔
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);
80✔
41

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

48
        this.builder = new ProgramBuilder({
80✔
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}]`;
80✔
54
        this.disposables.push(this.builder);
80✔
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)) {
80✔
59
            cwd = path.dirname(this.bsconfigPath);
32✔
60
            //load the bsconfig file contents (used for performance optimizations externally)
61
            try {
32✔
62
                this.bsconfigFileContents = (await fsExtra.readFile(this.bsconfigPath)).toString();
32✔
63
            } catch { }
64

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

71
        const builderOptions = {
80✔
72
            cwd: cwd,
73
            project: this.bsconfigPath,
74
            watch: false,
75
            createPackage: false,
76
            deploy: false,
77
            copyToStaging: false,
78
            showDiagnosticsInConsole: false,
79
            skipInitialValidation: true
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) {
80✔
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({
80✔
89
            ...builderOptions,
90
            skipInitialValidation: true,
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({
80✔
98
            name: 'bsc-language-server',
99
            afterProgramValidate: () => {
100
                const diagnostics = this.getDiagnostics();
116✔
101
                this.emit('diagnostics', {
116✔
102
                    diagnostics: diagnostics
103
                });
104
            }
105
        } as CompilerPlugin);
106

107
        //if we found a deprecated brsconfig.json, add a diagnostic warning the user
108
        if (this.bsconfigPath && path.basename(this.bsconfigPath) === 'brsconfig.json') {
80✔
109
            this.builder.addDiagnostic(this.bsconfigPath, {
1✔
110
                ...DiagnosticMessages.brsConfigJsonIsDeprecated(),
111
                range: util.createRange(0, 0, 0, 0)
112
            });
113
        }
114

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

118
        this.activationDeferred.resolve();
80✔
119

120
        return {
80✔
121
            bsconfigPath: this.bsconfigPath,
122
            logLevel: this.builder.program.options.logLevel as LogLevel,
123
            rootDir: this.builder.program.options.rootDir,
124
            filePatterns: this.filePatterns
125
        };
126
    }
127

128
    public isStandaloneProject = false;
85✔
129

130
    public logger: Logger;
131

132
    /**
133
     * Options used to activate this project
134
     */
135
    public activateOptions: ProjectConfig;
136

137
    public get rootDir() {
138
        return this.builder.program.options.rootDir;
120✔
139
    }
140
    /**
141
     * The file patterns from bsconfig.json that were used to find all files for this project
142
     */
143
    public get filePatterns() {
144
        if (!this.builder) {
200!
UNCOV
145
            return undefined;
×
146
        }
147
        const patterns = rokuDeploy.normalizeFilesArray(this.builder.program.options.files);
200✔
148
        return patterns.map(x => {
200✔
149
            return typeof x === 'string' ? x : x.src;
646✔
150
        });
151
    }
152

153
    /**
154
     * Gets resolved when the project has finished activating
155
     */
156
    private activationDeferred = new Deferred();
85✔
157

158
    /**
159
     * Promise that resolves when the project finishes activating
160
     * @returns a promise that resolves when the project finishes activating
161
     */
162
    public whenActivated() {
163
        return this.activationDeferred.promise;
209✔
164
    }
165

166
    private validationCancelToken: CancellationTokenSource;
167

168
    /**
169
     * Validate the project. This will trigger a full validation on any scopes that were changed since the last validation,
170
     * and will also eventually emit a new 'diagnostics' event that includes all diagnostics for the project.
171
     *
172
     * This will cancel any currently running validation and then run a new one.
173
     */
174
    public async validate() {
175
        this.logger.debug('Project.validate');
117✔
176

177
        this.cancelValidate();
117✔
178
        //store
179
        this.validationCancelToken = new CancellationTokenSource();
117✔
180

181
        try {
117✔
182
            this.emit('validate-begin', {});
117✔
183
            await this.builder.program.validate({
117✔
184
                async: true,
185
                cancellationToken: this.validationCancelToken.token
186
            });
187
        } finally {
188
            this.emit('validate-end', {});
117✔
189
        }
190
    }
191

192
    /**
193
     * Cancel any active validation that's running
194
     */
195
    public cancelValidate() {
196
        this.validationCancelToken?.cancel();
117✔
197
        delete this.validationCancelToken;
117✔
198
    }
199

200
    public getDiagnostics() {
201
        const diagnostics = this.builder.getDiagnostics();
119✔
202
        return diagnostics.map(x => {
119✔
203
            const uri = URI.file(x.file.srcPath).toString();
72✔
204
            return {
72✔
205
                ...util.toDiagnostic(x, uri),
206
                uri: uri
207
            };
208
        });
209
    }
210

211
    /**
212
     * Promise that resolves the next time the system is idle. If the system is already idle, it will resolve immediately
213
     */
214
    private async onIdle(): Promise<void> {
215
        await Promise.all([
69✔
216
            this.activationDeferred.promise
217
        ]);
218
    }
219

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

227
        await this.onIdle();
43✔
228

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

240
                //if we got file contents, set the file on the program
241
                if (fileContents !== undefined) {
67✔
242
                    didChangeThisFile = this.setFile(action.srcPath, fileContents);
65✔
243
                    //this file was accepted by the program
244
                    action.status = 'accepted';
65✔
245

246
                    //if we can't get file contents, apply this as a delete
247
                } else {
248
                    action.status = 'accepted';
2✔
249
                    result.push({
2✔
250
                        id: action.id,
251
                        srcPath: action.srcPath,
252
                        type: 'delete',
253
                        status: undefined,
254
                        allowStandaloneProject: false
255
                    });
256
                    continue;
2✔
257
                }
258

259
                //try to delete the file or directory
260
            } else if (action.type === 'delete') {
8✔
261
                didChangeThisFile = this.removeFileOrDirectory(action.srcPath);
5✔
262
                //if we deleted at least one file, mark this action as accepted
263
                action.status = didChangeThisFile ? 'accepted' : 'rejected';
5✔
264

265
                //we did not handle this action, so reject
266
            } else {
267
                action.status = 'rejected';
3✔
268
            }
269
            didChangeFiles = didChangeFiles || didChangeThisFile;
73✔
270
        }
271
        if (didChangeFiles) {
43✔
272
            //trigger a validation (but don't wait for it. That way we can cancel it sooner if we get new incoming data or requests)
273
            this.validate().catch(e => this.logger.error(e));
34✔
274
        }
275

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

278
        return result;
43✔
279
    }
280

281
    /**
282
     * 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)
283
     */
284
    public static willAcceptFile(srcPath: string, files: BsConfig['files'], rootDir: string) {
285
        srcPath = util.standardizePath(srcPath);
70✔
286
        if (rokuDeploy.getDestPath(srcPath, files, rootDir) !== undefined) {
70✔
287
            return true;
67✔
288
            //is this exact path in the `files` array? (this check is mostly for standalone projects)
289
        } else if ((files as StandardizedFileEntry[]).find(x => s`${x.src}` === srcPath)) {
4!
UNCOV
290
            return true;
×
291
        }
292
        return false;
3✔
293
    }
294

295
    /**
296
     * Set new contents for a file. This is safe to call any time. Changes will be queued and flushed at the correct times
297
     * during the program's lifecycle flow
298
     * @param srcPath absolute source path of the file
299
     * @param fileContents the text contents of the file
300
     * @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
301
     */
302
    private setFile(srcPath: string, fileContents: string) {
303
        this.logger.debug('Project.setFile', { srcPath: srcPath, fileContentsLength: fileContents.length });
65✔
304

305
        const { files, rootDir } = this.builder.program.options;
65✔
306

307
        //get the dest path for this file.
308
        let destPath = rokuDeploy.getDestPath(srcPath, files, rootDir);
65✔
309

310
        //if we have a file and the contents haven't changed
311
        let file = this.builder.program.getFile(destPath);
65✔
312
        if (file && file.fileContents === fileContents) {
65✔
313
            return false;
7✔
314
        }
315

316
        //if we got a dest path, then the program wants this file
317
        if (destPath) {
58!
318
            this.builder.program.setFile(
58✔
319
                {
320
                    src: srcPath,
321
                    dest: destPath
322
                },
323
                fileContents
324
            );
325
            return true;
58✔
326
        }
UNCOV
327
        return false;
×
328
    }
329

330
    /**
331
     * Remove the in-memory file at the specified path. This is typically called when the user (or file system watcher) triggers a file delete
332
     * @param srcPath absolute path to the File
333
     * @returns true if we found and removed at least one file, or false if no files were removed
334
     */
335
    private removeFileOrDirectory(srcPath: string) {
336
        this.logger.debug('Project.removeFileOrDirectory', srcPath);
5✔
337

338
        srcPath = util.standardizePath(srcPath);
5✔
339
        //if this is a direct file match, remove the file
340
        if (this.builder.program.hasFile(srcPath)) {
5✔
341
            this.builder.program.removeFile(srcPath);
1✔
342
            return true;
1✔
343
        }
344

345
        //maybe this is a directory. Remove all files that start with this path
346
        let removedSomeFiles = false;
4✔
347
        let lowerSrcPath = srcPath.toLowerCase();
4✔
348
        for (let file of Object.values(this.builder.program.files)) {
4✔
349
            //if the file path starts with the parent path and the file path does not exactly match the folder path
350
            if (file.srcPath?.toLowerCase().startsWith(lowerSrcPath)) {
4!
351
                this.logger.debug('Project.removeFileOrDirectory removing file because it matches the given directory', { dir: srcPath, srcPath: file.srcPath });
3✔
352

353
                this.builder.program.removeFile(file.srcPath, false);
3✔
354
                removedSomeFiles = true;
3✔
355
            }
356
        }
357
        //return true if we removed at least one file
358
        return removedSomeFiles;
4✔
359
    }
360

361
    /**
362
     * Get the full list of semantic tokens for the given file path
363
     * @param options options for getting semantic tokens
364
     * @param options.srcPath absolute path to the source file
365
     */
366
    public async getSemanticTokens(options: { srcPath: string }) {
367
        await this.onIdle();
1✔
368
        if (this.builder.program.hasFile(options.srcPath)) {
1!
369
            return this.builder.program.getSemanticTokens(options.srcPath);
1✔
370
        }
371
    }
372

373
    public async transpileFile(options: { srcPath: string }) {
374
        await this.onIdle();
2✔
375
        if (this.builder.program.hasFile(options.srcPath)) {
2!
376
            return this.builder.program.getTranspiledFileContents(options.srcPath);
2✔
377
        }
378
    }
379

380
    public async getHover(options: { srcPath: string; position: Position }): Promise<Hover[]> {
UNCOV
381
        await this.onIdle();
×
UNCOV
382
        if (this.builder.program.hasFile(options.srcPath)) {
×
UNCOV
383
            return this.builder.program.getHover(options.srcPath, options.position);
×
384
        }
385
    }
386

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

394
    public async getSignatureHelp(options: { srcPath: string; position: Position }): Promise<SignatureInfoObj[]> {
395
        await this.onIdle();
4✔
396
        if (this.builder.program.hasFile(options.srcPath)) {
4!
397
            return this.builder.program.getSignatureHelp(options.srcPath, options.position);
4✔
398
        }
399
    }
400

401
    public async getDocumentSymbol(options: { srcPath: string }): Promise<DocumentSymbol[]> {
402
        await this.onIdle();
6✔
403
        if (this.builder.program.hasFile(options.srcPath)) {
6!
404
            return this.builder.program.getDocumentSymbols(options.srcPath);
6✔
405
        }
406
    }
407

408
    public async getWorkspaceSymbol(): Promise<WorkspaceSymbol[]> {
409
        await this.onIdle();
4✔
410
        const result = this.builder.program.getWorkspaceSymbols();
4✔
411
        return result;
4✔
412
    }
413

414
    public async getReferences(options: { srcPath: string; position: Position }) {
415
        await this.onIdle();
3✔
416
        if (this.builder.program.hasFile(options.srcPath)) {
3!
417
            return this.builder.program.getReferences(options.srcPath, options.position);
3✔
418
        }
419
    }
420

421
    public async getCodeActions(options: { srcPath: string; range: Range }) {
UNCOV
422
        await this.onIdle();
×
423

UNCOV
424
        if (this.builder.program.hasFile(options.srcPath)) {
×
UNCOV
425
            const codeActions = this.builder.program.getCodeActions(options.srcPath, options.range);
×
426
            //clone each diagnostic since certain diagnostics can have circular reference properties that kill the language server if serialized
UNCOV
427
            for (const codeAction of codeActions ?? []) {
×
428
                if (codeAction.diagnostics) {
×
UNCOV
429
                    codeAction.diagnostics = codeAction.diagnostics?.map(x => util.toDiagnostic(x, options.srcPath));
×
430
                }
431
            }
UNCOV
432
            return codeActions;
×
433
        }
434
    }
435

436
    public async getCompletions(options: { srcPath: string; position: Position }): Promise<CompletionList> {
437
        await this.onIdle();
1✔
438

439
        this.logger.debug('Project.getCompletions', options.srcPath, options.position);
1✔
440

441
        if (this.builder.program.hasFile(options.srcPath)) {
1!
442
            const completions = this.builder.program.getCompletions(options.srcPath, options.position);
1✔
443
            const result = CompletionList.create(completions);
1✔
444
            result.itemDefaults = {
1✔
445
                commitCharacters: ['.']
446
            };
447
            return result;
1✔
448
        }
UNCOV
449
        return undefined;
×
450
    }
451

452
    /**
453
     * Manages the BrighterScript program. The main interface into the compiler/validator
454
     */
455
    private builder: ProgramBuilder;
456

457
    /**
458
     * The path to where the project resides
459
     */
460
    public projectPath: string;
461

462
    /**
463
     * A unique number for this project, generated during this current language server session. Mostly used so we can identify which project is doing logging
464
     */
465
    public projectNumber: number;
466

467
    /**
468
     * A unique name for this project used in logs to help keep track of everything
469
     */
470
    public projectIdentifier: string;
471

472
    /**
473
     * The path to the workspace where this project resides. A workspace can have multiple projects (by adding a bsconfig.json to each folder).
474
     * Defaults to `.projectPath` if not set
475
     */
476
    public workspaceFolder: string;
477

478
    /**
479
     * Path to a bsconfig.json file that will be used for this project
480
     */
481
    public bsconfigPath?: string;
482

483
    /**
484
     * 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).
485
     *
486
     * Only available after `.activate()` has completed.
487
     * @deprecated do not depend on this property. This will certainly be removed in a future release
488
     */
489
    public bsconfigFileContents?: string;
490

491
    /**
492
     * Find the path to the bsconfig.json file for this project
493
     * @returns path to bsconfig.json, or undefined if unable to find it
494
     */
495
    private async getConfigFilePath(config: { configFilePath?: string; projectPath: string }) {
496
        let configFilePath: string;
497
        //if there's a setting, we need to find the file or show error if it can't be found
498
        if (config?.configFilePath) {
83✔
499
            configFilePath = path.resolve(config.projectPath, config.configFilePath);
2✔
500
            if (await util.pathExists(configFilePath)) {
2✔
501
                return util.standardizePath(configFilePath);
1✔
502
            } else {
503
                this.emit('critical-failure', {
1✔
504
                    message: `Cannot find config file specified in user or workspace settings at '${configFilePath}'`
505
                });
506
            }
507
        }
508

509
        //the rest of these require a projectPath, so return early if we don't have one
510
        if (!config?.projectPath) {
82✔
511
            return undefined;
1✔
512
        }
513

514
        //default to config file path found in the root of the workspace
515
        configFilePath = s`${config.projectPath}/bsconfig.json`;
81✔
516
        if (await util.pathExists(configFilePath)) {
81✔
517
            return util.standardizePath(configFilePath);
31✔
518
        }
519

520
        //look for the deprecated `brsconfig.json` file
521
        configFilePath = s`${config.projectPath}/brsconfig.json`;
50✔
522
        if (await util.pathExists(configFilePath)) {
50✔
523
            return util.standardizePath(configFilePath);
1✔
524
        }
525

526
        //no config file could be found
527
        return undefined;
49✔
528
    }
529

530
    public on(eventName: 'validate-begin', handler: (data: any) => MaybePromise<void>);
531
    public on(eventName: 'validate-end', handler: (data: any) => MaybePromise<void>);
532
    public on(eventName: 'critical-failure', handler: (data: { message: string }) => MaybePromise<void>);
533
    public on(eventName: 'diagnostics', handler: (data: { diagnostics: LspDiagnostic[] }) => MaybePromise<void>);
534
    public on(eventName: 'all', handler: (eventName: string, data: any) => MaybePromise<void>);
535
    public on(eventName: string, handler: (...args: any[]) => MaybePromise<void>) {
536
        this.emitter.on(eventName, handler as any);
75✔
537
        return () => {
75✔
538
            this.emitter.removeListener(eventName, handler as any);
1✔
539
        };
540
    }
541

542
    private emit(eventName: 'validate-begin', data: any);
543
    private emit(eventName: 'validate-end', data: any);
544
    private emit(eventName: 'critical-failure', data: { message: string });
545
    private emit(eventName: 'diagnostics', data: { diagnostics: LspDiagnostic[] });
546
    private async emit(eventName: string, data?) {
547
        //emit these events on next tick, otherwise they will be processed immediately which could cause issues
548
        await util.sleep(0);
354✔
549
        this.emitter.emit(eventName, data);
354✔
550
        //emit the 'all' event
551
        this.emitter.emit('all', eventName, data);
354✔
552
    }
553
    private emitter = new EventEmitter();
85✔
554

555
    public disposables: LspProject['disposables'] = [];
85✔
556

557
    public dispose() {
558
        for (let disposable of this.disposables ?? []) {
85!
559
            disposable?.dispose?.();
152!
560
        }
561
        this.disposables = [];
85✔
562

563
        this.emitter?.removeAllListeners();
85!
564
        if (this.activationDeferred?.isCompleted === false) {
85!
565
            this.activationDeferred.reject(
5✔
566
                new Error('Project was disposed, activation has been cancelled')
567
            );
568
        }
569
    }
570
}
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